From 10b31136213f820939cabfc83ac1d78e1aaabd92 Mon Sep 17 00:00:00 2001 From: Matias Date: Tue, 16 Sep 2025 15:46:05 -0300 Subject: [PATCH 01/96] Subo unas reglas del Eslinter --- eslint.config.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/eslint.config.js b/eslint.config.js index ec2b712..ad2e3d9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,6 +2,7 @@ import js from '@eslint/js' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' +import react from 'eslint-plugin-react' export default [ { ignores: ['dist'] }, @@ -17,17 +18,26 @@ export default [ }, }, plugins: { + 'react': react, 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...js.configs.recommended.rules, + ...react.configs.recommended.rules, ...reactHooks.configs.recommended.rules, 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], 'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, ], + 'react/react-in-jsx-scope': 'off', // innecesario en React 17+ + 'react/prop-types': 'off' // si no usás PropTypes porque usás TS }, + settings: { + react: { + version: 'detect' + } + } }, ] From a615cd3534fc0777640111aed3919b098b4711b6 Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Wed, 17 Sep 2025 15:56:48 -0300 Subject: [PATCH 02/96] uso del .env en las url --- src/components/Editar/editar.jsx | 18 ++++++++++-------- .../PanelAdmin/sections/ReportesAdmin.jsx | 4 ++-- .../PanelAdmin/sections/UsuariosAdmin.jsx | 2 +- src/components/Publicar/Publicar.jsx | 14 ++++++++------ src/components/Reportes/Reportes.jsx | 3 ++- src/components/buscar/Buscar.jsx | 6 ++++-- src/components/login/GoogleLogin.jsx | 3 ++- src/components/publicacion/Publicacion.jsx | 16 ++++++++-------- src/services/busquedaService.js | 4 ++-- src/services/notificaciones.js | 3 ++- src/services/perfilService.js | 2 +- src/utils/socket.js | 2 +- 12 files changed, 43 insertions(+), 34 deletions(-) diff --git a/src/components/Editar/editar.jsx b/src/components/Editar/editar.jsx index 067e6ca..888298e 100644 --- a/src/components/Editar/editar.jsx +++ b/src/components/Editar/editar.jsx @@ -113,8 +113,10 @@ export default function Editar() { const camposValidos = (campo) => !errores.includes(campo); + const API_URL = import.meta.env.VITE_API_URL; + useEffect(() => { - fetch('http://localhost:5000/api/ubicacion/provincias') + fetch(`${API_URL}api/ubicacion/provincias`) .then(res => res.json()) .then(setProvincias) .catch(console.error); @@ -122,7 +124,7 @@ export default function Editar() { useEffect(() => { if (provinciaId) { - fetch(`http://localhost:5000/api/ubicacion/departamentos?provincia_id=${provinciaId}`) + fetch(`${API_URL}api/ubicacion/departamentos?provincia_id=${provinciaId}`) .then(res => res.json()) .then(setDepartamentos); } else { @@ -133,7 +135,7 @@ export default function Editar() { useEffect(() => { if (departamentoId) { - fetch(`http://localhost:5000/api/ubicacion/localidades?departamento_id=${departamentoId}`) + fetch(`${API_URL}api/ubicacion/localidades?departamento_id=${departamentoId}`) .then(res => res.json()) .then(setLocalidades); } else { @@ -143,7 +145,7 @@ export default function Editar() { }, [departamentoId]); useEffect(() => { - fetch('http://localhost:5000/api/etiquetas') + fetch(`${API_URL}api/etiquetas`) .then(res => res.json()) .then(data => { const mapped = data.map(e => ({ label: e.nombre, id: e.id })); @@ -154,7 +156,7 @@ export default function Editar() { useEffect(() => { const fetchDatosPublicacion = async () => { try { - const res = await fetch(`http://localhost:5000/publicaciones/${id_publicacion}`); + const res = await fetch(`${API_URL}publicaciones/${id_publicacion}`); if (!res.ok) throw new Error("No se pudo obtener la publicación"); const data = await res.json(); @@ -165,7 +167,7 @@ export default function Editar() { setCoordenadas({ lat: parseFloat(data.coordenadas[0]), lng: parseFloat(data.coordenadas[1]) }); } if (data.id_locacion) { - const resLoc = await fetch(`http://localhost:5000/api/ubicacion/localidades/${data.id_locacion}`); + const resLoc = await fetch(`${API_URL}api/ubicacion/localidades/${data.id_locacion}`); if (resLoc.ok) { const localidad = await resLoc.json(); setProvinciaId(localidad.id_provincia.toString()); @@ -231,7 +233,7 @@ export default function Editar() { formData.append("imagenes", img); }); - const resImagenes = await fetch("http://localhost:5000/subir-imagenes", { + const resImagenes = await fetch(`${API_URL}subir-imagenes`, { method: "POST", body: formData, }); @@ -263,7 +265,7 @@ export default function Editar() { } const token = await user.getIdToken(); - const res = await fetch(`http://localhost:5000/publicaciones/${id_publicacion}`, { + const res = await fetch(`${API_URL}publicaciones/${id_publicacion}`, { method: "PATCH", headers: { "Content-Type": "application/json", diff --git a/src/components/PanelAdmin/sections/ReportesAdmin.jsx b/src/components/PanelAdmin/sections/ReportesAdmin.jsx index b8ff6e4..d13e725 100644 --- a/src/components/PanelAdmin/sections/ReportesAdmin.jsx +++ b/src/components/PanelAdmin/sections/ReportesAdmin.jsx @@ -71,7 +71,7 @@ export default function ReportesAdmin() { const fetchReportes = async () => { try { setLoading(true); - const res = await fetch(`${API_URL}/reportes`); + const res = await fetch(`${API_URL}reportes`); const data = await res.json(); const formateados = data.map((r) => ({ @@ -94,7 +94,7 @@ export default function ReportesAdmin() { // Eliminar reporte const handleEliminar = async (id) => { try { - await fetch(`${API_URL}/reportes/${id}`, { + await fetch(`${API_URL}reportes/${id}`, { method: "DELETE", }); setRows(rows.filter((r) => r.id !== id)); diff --git a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx index 9f5cc1a..5f5d52a 100644 --- a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx +++ b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx @@ -85,7 +85,7 @@ export default function UsuariosAdmin() { setLoading(true); try { const response = await fetch( - `${API_URL}/api/usuarios?page=${page + 1}&per_page=${pageSize}&search=${encodeURIComponent(search)}` + `${API_URL}api/usuarios?page=${page + 1}&per_page=${pageSize}&search=${encodeURIComponent(search)}` ); const data = await response.json(); setRows(data.usuarios); diff --git a/src/components/Publicar/Publicar.jsx b/src/components/Publicar/Publicar.jsx index 0cf867d..bb5f598 100644 --- a/src/components/Publicar/Publicar.jsx +++ b/src/components/Publicar/Publicar.jsx @@ -93,6 +93,8 @@ export default function Publicar() { const navigate = useNavigate(); const [cargando, setCargando] = useState(false); + const API_URL = import.meta.env.VITE_API_URL; + const validarCampos = () => { const nuevosErrores = []; if (!seleccionado) nuevosErrores.push("Categoría"); @@ -110,7 +112,7 @@ export default function Publicar() { const camposValidos = (campo) => !errores.includes(campo); useEffect(() => { - fetch('http://localhost:5000/api/ubicacion/provincias') + fetch(`${API_URL}api/ubicacion/provincias`) .then(res => res.json()) .then(setProvincias) .catch(console.error); @@ -118,7 +120,7 @@ export default function Publicar() { useEffect(() => { if (provinciaId) { - fetch(`http://localhost:5000/api/ubicacion/departamentos?provincia_id=${provinciaId}`) + fetch(`${API_URL}api/ubicacion/departamentos?provincia_id=${provinciaId}`) .then(res => res.json()) .then(setDepartamentos); } else { @@ -129,7 +131,7 @@ export default function Publicar() { useEffect(() => { if (departamentoId) { - fetch(`http://localhost:5000/api/ubicacion/localidades?departamento_id=${departamentoId}`) + fetch(`${API_URL}api/ubicacion/localidades?departamento_id=${departamentoId}`) .then(res => res.json()) .then(setLocalidades); } else { @@ -139,7 +141,7 @@ export default function Publicar() { }, [departamentoId]); useEffect(() => { - fetch('http://localhost:5000/api/etiquetas') + fetch(`${API_URL}api/etiquetas`) .then(res => res.json()) .then(data => { const mapped = data.map(e => ({ label: e.nombre, id: e.id })); @@ -175,7 +177,7 @@ export default function Publicar() { formData.append("imagenes", img); }); - const resImagenes = await fetch("http://localhost:5000/subir-imagenes", { + const resImagenes = await fetch(`${API_URL}subir-imagenes`, { method: "POST", body: formData, }); @@ -207,7 +209,7 @@ export default function Publicar() { } const token = await user.getIdToken(); - const res = await fetch("http://localhost:5000/publicaciones", { + const res = await fetch(`${API_URL}publicaciones`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/components/Reportes/Reportes.jsx b/src/components/Reportes/Reportes.jsx index d87524c..1472a70 100644 --- a/src/components/Reportes/Reportes.jsx +++ b/src/components/Reportes/Reportes.jsx @@ -6,6 +6,7 @@ export default function ReporteForm({ idPublicacion, idUsuario, onClose }) { const [descripcion, setDescripcion] = useState(""); const [loading, setLoading] = useState(false); const [mensaje, setMensaje] = useState(null); + const API_URL = import.meta.env.VITE_API_URL; const handleSubmit = async (e) => { e.preventDefault(); @@ -27,7 +28,7 @@ export default function ReporteForm({ idPublicacion, idUsuario, onClose }) { try { const token = await user.getIdToken(); - const response = await fetch("http://127.0.0.1:5000/reportes", { + const response = await fetch(`${API_URL}reportes`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/components/buscar/Buscar.jsx b/src/components/buscar/Buscar.jsx index f581285..332c233 100644 --- a/src/components/buscar/Buscar.jsx +++ b/src/components/buscar/Buscar.jsx @@ -13,6 +13,8 @@ const categoriasPosibles = [ { label: "Estado crítico", value: "Estado Crítico" } ]; +const API_URL = import.meta.env.VITE_API_URL; + const Buscar = () => { const navigate = useNavigate(); const [idPublicacion, setIdPublicacion] = useState(null); @@ -38,7 +40,7 @@ const Buscar = () => { useEffect(() => { // Obtener etiquetas desde backend - fetch('http://localhost:5000/api/etiquetas') + fetch(`${API_URL}api/etiquetas`) .then(res => res.json()) .then(data => { const mapped = data.map(e => ({ label: e.nombre, id: e.id })); @@ -66,7 +68,7 @@ const Buscar = () => { const cargarPublicaciones = async () => { try { setLoading(true); // ✅ activa loading - const res = await fetch("http://127.0.0.1:5000/publicaciones"); + const res = await fetch(`${API_URL}publicaciones`); const data = await res.json(); setPublicaciones(data); } catch (error) { diff --git a/src/components/login/GoogleLogin.jsx b/src/components/login/GoogleLogin.jsx index 610164b..75a62ce 100644 --- a/src/components/login/GoogleLogin.jsx +++ b/src/components/login/GoogleLogin.jsx @@ -6,6 +6,7 @@ import '../../styles/global.css'; import iconoGOOGLE from '../../assets/iconoGOOGLE.svg'; +const API_URL = import.meta.env.VITE_API_URL; function Login() { const navigate = useNavigate(); @@ -23,7 +24,7 @@ function Login() { localStorage.setItem("userEmail", user.email); localStorage.setItem("token", idToken); - const response = await fetch("http://localhost:5000/api/login", { + const response = await fetch(`${API_URL}api/etiquetas`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: idToken }), diff --git a/src/components/publicacion/Publicacion.jsx b/src/components/publicacion/Publicacion.jsx index f7f85f1..4e0e069 100644 --- a/src/components/publicacion/Publicacion.jsx +++ b/src/components/publicacion/Publicacion.jsx @@ -118,11 +118,11 @@ export default function Publicacion() { const [mostrarModal, setMostrarModal] = useState(false); const navigate = useNavigate(); - + const API_URL = import.meta.env.VITE_API_URL; // Obtener publicación useEffect(() => { axios - .get(`http://127.0.0.1:5000/publicaciones/${id}`) + .get(`${API_URL}publicaciones/${id}`) .then((res) => { setPublicacion(res.data); setLoading(false); @@ -137,7 +137,7 @@ export default function Publicacion() { useEffect(() => { if (publicacion?.id_usuario) { axios - .get(`http://127.0.0.1:5000/usuario/${publicacion.id_usuario}`) + .get(`${API_URL}usuario/${publicacion.id_usuario}`) .then((res) => setUsuario(res.data)) .catch((err) => console.error("Error al obtener el usuario:", err)); } @@ -147,7 +147,7 @@ export default function Publicacion() { if (!id) return; axios - .get(`http://localhost:5000/comentarios/publicacion/${id}`) + .get(`${API_URL}comentarios/publicacion/${id}`) .then(async (res) => { const comentarios = res.data; setComentarios(comentarios); @@ -160,7 +160,7 @@ export default function Publicacion() { await Promise.all( idsUnicos.map(async (idUsuario) => { try { - const res = await axios.get(`http://localhost:5000/usuario/${idUsuario}`); + const res = await axios.get(`${API_URL}usuario/${idUsuario}`); usuariosMap[idUsuario] = res.data; } catch (err) { console.error(`Error al obtener usuario ${idUsuario}`, err); @@ -221,7 +221,7 @@ export default function Publicacion() { const descargarPDF = async (idPublicacion) => { try { - const response = await axios.get(`http://127.0.0.1:5000/pdf/${idPublicacion}`, { + const response = await axios.get(`${API_URL}pdf/${idPublicacion}`, { responseType: "blob", }); @@ -272,7 +272,7 @@ export default function Publicacion() { try { const token = await user.getIdToken(); - const res = await fetch("http://127.0.0.1:5000/comentarios", { + const res = await fetch(`${API_URL}comentarios`, { method: "POST", headers: { "Content-Type": "application/json", @@ -291,7 +291,7 @@ export default function Publicacion() { setNuevoComentario(""); // Recargar comentarios - const comentariosRes = await fetch(`http://127.0.0.1:5000/comentarios/publicacion/${id}`); + const comentariosRes = await fetch(`${API_URL}comentarios/publicacion/${id}`); const comentariosData = await comentariosRes.json(); setComentarios(comentariosData); } else { diff --git a/src/services/busquedaService.js b/src/services/busquedaService.js index 3746d98..0bec850 100644 --- a/src/services/busquedaService.js +++ b/src/services/busquedaService.js @@ -1,6 +1,6 @@ import Publicacion from '../models/publicacion'; - -const BASE_URL = 'http://localhost:5000'; +const API_URL = import.meta.env.VITE_API_URL; +const BASE_URL = `${API_URL}`; // Función para obtener publicaciones filtradas desde el backend export async function fetchPublicacionesFiltradas(params) { diff --git a/src/services/notificaciones.js b/src/services/notificaciones.js index 9b20d94..87eef05 100644 --- a/src/services/notificaciones.js +++ b/src/services/notificaciones.js @@ -1,4 +1,5 @@ -const BASE_URL = 'http://localhost:5000'; +const API_URL = import.meta.env.VITE_API_URL; +const BASE_URL = `${API_URL}`; // Función para marcar notificación como leída export async function marcarNotificacionLeida(idNotificacion) { diff --git a/src/services/perfilService.js b/src/services/perfilService.js index 6caaf6a..c72478b 100644 --- a/src/services/perfilService.js +++ b/src/services/perfilService.js @@ -93,7 +93,7 @@ export function fetchUsuarioActual() { try { const token = await user.getIdToken(); - const res = await fetch('http://localhost:5000/usuarios/me', { + const res = await fetch(`${API_URL}usuarios/me`, { headers: { Authorization: `Bearer ${token}` } diff --git a/src/utils/socket.js b/src/utils/socket.js index bdc6082..89ad1c8 100644 --- a/src/utils/socket.js +++ b/src/utils/socket.js @@ -5,7 +5,7 @@ import {Notificacion} from '../utils/toastUtil' -const baseURL = "http://localhost:5000" +const baseURL = "https://backendconflask.onrender.com" //socket para registrasrse como user connected, usando un token From fb8c19c7da760f0412ec0a91e853c7a104972f9c Mon Sep 17 00:00:00 2001 From: Matias Date: Wed, 17 Sep 2025 16:27:46 -0300 Subject: [PATCH 03/96] =?UTF-8?q?Arreglo=20el=20inicio=20de=20sesi=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/login/GoogleLogin.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/login/GoogleLogin.jsx b/src/components/login/GoogleLogin.jsx index 75a62ce..f490a7f 100644 --- a/src/components/login/GoogleLogin.jsx +++ b/src/components/login/GoogleLogin.jsx @@ -24,7 +24,7 @@ function Login() { localStorage.setItem("userEmail", user.email); localStorage.setItem("token", idToken); - const response = await fetch(`${API_URL}api/etiquetas`, { + const response = await fetch(`${API_URL}api/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: idToken }), From 6764b3dd4d74dc6cb63bfa42c1215053048e99ed Mon Sep 17 00:00:00 2001 From: Smartinez117 Date: Sat, 20 Sep 2025 15:10:35 -0300 Subject: [PATCH 04/96] quitado los loggs del console --- src/utils/socket.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/utils/socket.js b/src/utils/socket.js index bdc6082..6f63ebe 100644 --- a/src/utils/socket.js +++ b/src/utils/socket.js @@ -30,9 +30,6 @@ export function socketconnection(){ connection.on('connect_error', (error) => { console.error('Error en la conexión:', error.message); }); - connection.io.engine.on('packetCreate', (packet) => { - console.log('Enviando paquete:', packet); -}); return connection; // puede devolver o guardar la conexión para usarla después } catch (error) { console.error('Error obteniendo token:', error); From eb67bb71dafd08c350c1405059f6e192a8a28f7c Mon Sep 17 00:00:00 2001 From: Smartinez117 Date: Sat, 20 Sep 2025 15:13:01 -0300 Subject: [PATCH 05/96] nada importante --- src/utils/socket.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/socket.js b/src/utils/socket.js index 6f63ebe..373dbe8 100644 --- a/src/utils/socket.js +++ b/src/utils/socket.js @@ -43,7 +43,7 @@ export function socketnotificationlisten(useruid) { const notificacion = io(baseURL + '/notificacion/' + useruid, {}); notificacion.on('connect', () => { - console.log('Socket1 conectado'); + console.log('Socket-DE-notificaciones conectado'); }); notificacion.on('connect_error', (error) => { From 6cdcec4612875ee4d5c72ffc485c999bbc49fe3d Mon Sep 17 00:00:00 2001 From: walorey Date: Tue, 23 Sep 2025 12:43:07 -0300 Subject: [PATCH 06/96] agregado un modal general este modal va a usarse para editar las diferentes entidades, usuarios, ubicaciones, etiquetas, etc --- src/components/PanelAdmin/ModalGeneral.jsx | 53 +++++++ src/components/PanelAdmin/PanelAdmin.jsx | 28 +++- .../PanelAdmin/sections/UsuariosAdmin.jsx | 143 +++++++++--------- 3 files changed, 145 insertions(+), 79 deletions(-) create mode 100644 src/components/PanelAdmin/ModalGeneral.jsx diff --git a/src/components/PanelAdmin/ModalGeneral.jsx b/src/components/PanelAdmin/ModalGeneral.jsx new file mode 100644 index 0000000..7aadc7a --- /dev/null +++ b/src/components/PanelAdmin/ModalGeneral.jsx @@ -0,0 +1,53 @@ +// ModalGeneral.jsx +import * as React from 'react'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; + +export default function ModalGeneral({ open, entity, data, onClose, onSave }) { + const [formData, setFormData] = React.useState(data || {}); + + React.useEffect(() => { + setFormData(data || {}); + }, [data]); + + const handleChange = (e) => { + setFormData({ ...formData, [e.target.name]: e.target.value }); + }; + + const handleSave = () => { + onSave(formData); + }; + + return ( + + {entity ? `Editar ${entity}` : 'Modal'} + + {formData ? ( + <> + {Object.keys(formData).map((key) => ( + + ))} + + ) : ( +

No hay datos

+ )} +
+ + + + +
+ ); +} diff --git a/src/components/PanelAdmin/PanelAdmin.jsx b/src/components/PanelAdmin/PanelAdmin.jsx index 23370d2..f4a399b 100644 --- a/src/components/PanelAdmin/PanelAdmin.jsx +++ b/src/components/PanelAdmin/PanelAdmin.jsx @@ -9,8 +9,7 @@ import Navigator from './Navigator'; import Header from './Header'; import { useEffect, useState } from 'react'; import { getAuth, onAuthStateChanged, signOut } from 'firebase/auth'; - - +import ModalGeneral from './ModalGeneral'; let theme = createTheme({ @@ -160,6 +159,7 @@ const drawerWidth = 256; export default function PanelAdmin() { const [mobileOpen, setMobileOpen] = React.useState(false); + const [modalConfig, setModalConfig] = useState({ open: false }); const isSmUp = useMediaQuery(theme.breakpoints.up('sm')); const location = useLocation(); // 👈 para el título dinámico @@ -167,6 +167,13 @@ export default function PanelAdmin() { setMobileOpen(!mobileOpen); }; + const openModal = (entity, data) => { + setModalConfig({ + open: true, + entity, // "usuario" | "ubicacion" | "etiqueta" + data // objeto con valores actuales + }); + }; //Datos del usuario: const [userName, setUserName] = useState(''); @@ -249,11 +256,24 @@ export default function PanelAdmin() { gap: 2, }} > - {/* 👇 SOLO acá permito el scroll horizontal */} + {/*SOLO acá permito el scroll horizontal */} - + + + {/*Modal general */} + setModalConfig({ open: false })} + onSave={(updated) => { + console.log("Guardado:", updated); + setModalConfig({ open: false }); + }} + /> + diff --git a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx index 9f5cc1a..9e21299 100644 --- a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx +++ b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx @@ -1,9 +1,9 @@ import * as React from 'react'; -import { Link } from 'react-router-dom'; -import { useState, useEffect, useCallback } from 'react'; +import { Link, useOutletContext } from 'react-router-dom'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import Toolbar from '@mui/material/Toolbar'; import Box from '@mui/material/Box'; -import Grid from '@mui/material/GridLegacy'; +import Grid from '@mui/material/Grid'; import Button from '@mui/material/Button'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; @@ -14,65 +14,6 @@ import { DataGrid } from '@mui/x-data-grid'; const API_URL = import.meta.env.VITE_API_URL; -const columns = [ - { field: 'id', headerName: 'ID', width: 70 }, - { field: 'nombre', headerName: 'Nombre', width: 130 }, - { field: 'email', headerName: 'Email', width: 200 }, - { field: 'rol', headerName: 'Rol', description: 'Muestra si es admin o usuario común', width: 90 }, - { field: 'fecha_registro', headerName: 'Fecha de registro', width: 130 }, - { - field: 'acciones', - headerName: 'Acciones', - width: 350, - sortable: false, - filterable: false, - renderCell: (params) => { - const id = params.row.id; - const slug = params.row.slug; - return ( - <> - - - - - - ); - } - } -]; - export default function UsuariosAdmin() { const [rows, setRows] = useState([]); const [rowCount, setRowCount] = useState(0); @@ -81,6 +22,67 @@ export default function UsuariosAdmin() { const [page, setPage] = useState(0); // DataGrid usa 0-based const [pageSize, setPageSize] = useState(10); + const { openModal } = useOutletContext(); // <-- hook de Outlet + + const columns = useMemo(() => [ + { field: 'id', headerName: 'ID', width: 70 }, + { field: 'nombre', headerName: 'Nombre', width: 130 }, + { field: 'email', headerName: 'Email', width: 200 }, + { field: 'rol', headerName: 'Rol', width: 90 }, + { field: 'fecha_registro', headerName: 'Fecha de registro', width: 130 }, + { + field: 'acciones', + headerName: 'Acciones', + flex: 1, + minWidth: 350, + sortable: false, + filterable: false, + renderCell: (params) => { + const row = params.row; + return ( + <> + + + + + + ); + }, + }, + ], [openModal]); + const fetchUsuarios = useCallback(async () => { setLoading(true); try { @@ -97,19 +99,16 @@ export default function UsuariosAdmin() { } }, [page, pageSize, search]); - // Ejecutar fetch con debounce cuando cambia la búsqueda useEffect(() => { const timeout = setTimeout(() => { - setPage(0); // reset página al cambiar búsqueda + setPage(0); fetchUsuarios(); }, 500); - return () => clearTimeout(timeout); }, [search, page, pageSize, fetchUsuarios]); return ( - {/* Filtros y acciones */} @@ -121,17 +120,12 @@ export default function UsuariosAdmin() { placeholder="Buscar por Nombre o Email" value={search} onChange={(e) => setSearch(e.target.value)} - InputProps={{ - disableUnderline: true, - sx: { fontSize: 'default' }, - }} + InputProps={{ disableUnderline: true, sx: { fontSize: 'default' } }} variant="standard" /> - + fetchUsuarios()}> @@ -141,13 +135,12 @@ export default function UsuariosAdmin() { - {/* Tabla */} setPage(newPage)} From f7e3e993c462ead30d16a23c00fcb357f1d17fa1 Mon Sep 17 00:00:00 2001 From: walorey Date: Wed, 24 Sep 2025 17:47:27 -0300 Subject: [PATCH 07/96] modal general para la edicion de entidades --- src/components/PanelAdmin/ModalGeneral.jsx | 93 +++++++++++++------ src/components/PanelAdmin/PanelAdmin.jsx | 2 +- .../PanelAdmin/sections/UsuariosAdmin.jsx | 5 +- 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/src/components/PanelAdmin/ModalGeneral.jsx b/src/components/PanelAdmin/ModalGeneral.jsx index 7aadc7a..d008da8 100644 --- a/src/components/PanelAdmin/ModalGeneral.jsx +++ b/src/components/PanelAdmin/ModalGeneral.jsx @@ -1,52 +1,87 @@ -// ModalGeneral.jsx -import * as React from 'react'; +// src/components/PanelAdmin/ModalGeneral.jsx +import React, { useState, useEffect } from 'react'; import Dialog from '@mui/material/Dialog'; import DialogTitle from '@mui/material/DialogTitle'; import DialogContent from '@mui/material/DialogContent'; import DialogActions from '@mui/material/DialogActions'; import Button from '@mui/material/Button'; import TextField from '@mui/material/TextField'; +import { FormControl, InputLabel, MenuItem, Select } from '@mui/material'; -export default function ModalGeneral({ open, entity, data, onClose, onSave }) { - const [formData, setFormData] = React.useState(data || {}); +function capitalize(s) { + return typeof s === 'string' && s.length > 0 ? s[0].toUpperCase() + s.slice(1) : s; +} + +export default function ModalGeneral({ + open = false, + entity = '', + data = null, + fields = null, + onClose = () => {}, + onSave = () => {} +}) { + const [formData, setFormData] = useState(data || {}); - React.useEffect(() => { + useEffect(() => { setFormData(data || {}); }, [data]); - const handleChange = (e) => { - setFormData({ ...formData, [e.target.name]: e.target.value }); - }; + if (!open) return null; + + // Si no recibimos "fields", los generamos a partir de las keys de data (todos text) + const effectiveFields = Array.isArray(fields) && fields.length > 0 + ? fields + : (data ? Object.keys(data).map(k => ({ name: k, label: capitalize(k), type: 'text' })) : []); - const handleSave = () => { - onSave(formData); + const handleChange = (name, value) => { + setFormData(prev => ({ ...prev, [name]: value })); }; return ( - {entity ? `Editar ${entity}` : 'Modal'} + {entity ? `Editar ${capitalize(entity)}` : 'Editar'} - {formData ? ( - <> - {Object.keys(formData).map((key) => ( - - ))} - - ) : ( -

No hay datos

- )} + {effectiveFields.length === 0 &&

No hay campos para editar

} + {effectiveFields.map(field => { + const { name, label = capitalize(name), type = 'text', options = [] } = field; + const value = formData?.[name] ?? ''; + + if (type === 'select') { + return ( + + {label} + + + ); + } + + // por defecto text input + return ( + handleChange(name, e.target.value)} + /> + ); + })}
+ - +
); diff --git a/src/components/PanelAdmin/PanelAdmin.jsx b/src/components/PanelAdmin/PanelAdmin.jsx index f4a399b..5921e0e 100644 --- a/src/components/PanelAdmin/PanelAdmin.jsx +++ b/src/components/PanelAdmin/PanelAdmin.jsx @@ -8,7 +8,7 @@ import { Outlet, useLocation } from 'react-router-dom'; // 👈 IMPORTANTE PARA import Navigator from './Navigator'; import Header from './Header'; import { useEffect, useState } from 'react'; -import { getAuth, onAuthStateChanged, signOut } from 'firebase/auth'; +import { getAuth, onAuthStateChanged } from 'firebase/auth'; import ModalGeneral from './ModalGeneral'; diff --git a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx index 9e21299..989872e 100644 --- a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx +++ b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx @@ -56,7 +56,10 @@ export default function UsuariosAdmin() { color="primary" size="small" sx={{ mr: 1 }} - onClick={() => openModal("usuario", row)} + onClick={() => openModal("usuario", { nombre: row.nombre, rol: row.rol }, [ + { name: "nombre", label: "Nombre", type: "text" }, + { name: "rol", label: "Rol", type: "select", options: ["admin", "usuario"] } + ])} > Editar From 72551113e3c576dae73cdbb7d29c33d925e90c4e Mon Sep 17 00:00:00 2001 From: walorey Date: Wed, 24 Sep 2025 18:17:28 -0300 Subject: [PATCH 08/96] unos ajustes --- src/components/Editar/editar.jsx | 16 ++++++++-------- .../PanelAdmin/sections/ReportesAdmin.jsx | 4 ++-- .../PanelAdmin/sections/UsuariosAdmin.jsx | 2 +- src/components/Publicar/Publicar.jsx | 12 ++++++------ src/components/Reportes/Reportes.jsx | 2 +- src/components/buscar/Buscar.jsx | 4 ++-- src/components/login/GoogleLogin.jsx | 2 +- src/components/publicacion/Publicacion.jsx | 14 +++++++------- src/services/perfilService.js | 2 +- 9 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/components/Editar/editar.jsx b/src/components/Editar/editar.jsx index 888298e..111869b 100644 --- a/src/components/Editar/editar.jsx +++ b/src/components/Editar/editar.jsx @@ -116,7 +116,7 @@ export default function Editar() { const API_URL = import.meta.env.VITE_API_URL; useEffect(() => { - fetch(`${API_URL}api/ubicacion/provincias`) + fetch(`${API_URL}/api/ubicacion/provincias`) .then(res => res.json()) .then(setProvincias) .catch(console.error); @@ -124,7 +124,7 @@ export default function Editar() { useEffect(() => { if (provinciaId) { - fetch(`${API_URL}api/ubicacion/departamentos?provincia_id=${provinciaId}`) + fetch(`${API_URL}/api/ubicacion/departamentos?provincia_id=${provinciaId}`) .then(res => res.json()) .then(setDepartamentos); } else { @@ -135,7 +135,7 @@ export default function Editar() { useEffect(() => { if (departamentoId) { - fetch(`${API_URL}api/ubicacion/localidades?departamento_id=${departamentoId}`) + fetch(`${API_URL}/api/ubicacion/localidades?departamento_id=${departamentoId}`) .then(res => res.json()) .then(setLocalidades); } else { @@ -145,7 +145,7 @@ export default function Editar() { }, [departamentoId]); useEffect(() => { - fetch(`${API_URL}api/etiquetas`) + fetch(`${API_URL}/api/etiquetas`) .then(res => res.json()) .then(data => { const mapped = data.map(e => ({ label: e.nombre, id: e.id })); @@ -156,7 +156,7 @@ export default function Editar() { useEffect(() => { const fetchDatosPublicacion = async () => { try { - const res = await fetch(`${API_URL}publicaciones/${id_publicacion}`); + const res = await fetch(`${API_URL}/publicaciones/${id_publicacion}`); if (!res.ok) throw new Error("No se pudo obtener la publicación"); const data = await res.json(); @@ -167,7 +167,7 @@ export default function Editar() { setCoordenadas({ lat: parseFloat(data.coordenadas[0]), lng: parseFloat(data.coordenadas[1]) }); } if (data.id_locacion) { - const resLoc = await fetch(`${API_URL}api/ubicacion/localidades/${data.id_locacion}`); + const resLoc = await fetch(`${API_URL}/api/ubicacion/localidades/${data.id_locacion}`); if (resLoc.ok) { const localidad = await resLoc.json(); setProvinciaId(localidad.id_provincia.toString()); @@ -233,7 +233,7 @@ export default function Editar() { formData.append("imagenes", img); }); - const resImagenes = await fetch(`${API_URL}subir-imagenes`, { + const resImagenes = await fetch(`${API_URL}/subir-imagenes`, { method: "POST", body: formData, }); @@ -265,7 +265,7 @@ export default function Editar() { } const token = await user.getIdToken(); - const res = await fetch(`${API_URL}publicaciones/${id_publicacion}`, { + const res = await fetch(`${API_URL}/publicaciones/${id_publicacion}`, { method: "PATCH", headers: { "Content-Type": "application/json", diff --git a/src/components/PanelAdmin/sections/ReportesAdmin.jsx b/src/components/PanelAdmin/sections/ReportesAdmin.jsx index d13e725..b8ff6e4 100644 --- a/src/components/PanelAdmin/sections/ReportesAdmin.jsx +++ b/src/components/PanelAdmin/sections/ReportesAdmin.jsx @@ -71,7 +71,7 @@ export default function ReportesAdmin() { const fetchReportes = async () => { try { setLoading(true); - const res = await fetch(`${API_URL}reportes`); + const res = await fetch(`${API_URL}/reportes`); const data = await res.json(); const formateados = data.map((r) => ({ @@ -94,7 +94,7 @@ export default function ReportesAdmin() { // Eliminar reporte const handleEliminar = async (id) => { try { - await fetch(`${API_URL}reportes/${id}`, { + await fetch(`${API_URL}/reportes/${id}`, { method: "DELETE", }); setRows(rows.filter((r) => r.id !== id)); diff --git a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx index 07f8645..989872e 100644 --- a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx +++ b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx @@ -90,7 +90,7 @@ export default function UsuariosAdmin() { setLoading(true); try { const response = await fetch( - `${API_URL}api/usuarios?page=${page + 1}&per_page=${pageSize}&search=${encodeURIComponent(search)}` + `${API_URL}/api/usuarios?page=${page + 1}&per_page=${pageSize}&search=${encodeURIComponent(search)}` ); const data = await response.json(); setRows(data.usuarios); diff --git a/src/components/Publicar/Publicar.jsx b/src/components/Publicar/Publicar.jsx index bb5f598..1bc62d7 100644 --- a/src/components/Publicar/Publicar.jsx +++ b/src/components/Publicar/Publicar.jsx @@ -112,7 +112,7 @@ export default function Publicar() { const camposValidos = (campo) => !errores.includes(campo); useEffect(() => { - fetch(`${API_URL}api/ubicacion/provincias`) + fetch(`${API_URL}/api/ubicacion/provincias`) .then(res => res.json()) .then(setProvincias) .catch(console.error); @@ -120,7 +120,7 @@ export default function Publicar() { useEffect(() => { if (provinciaId) { - fetch(`${API_URL}api/ubicacion/departamentos?provincia_id=${provinciaId}`) + fetch(`${API_URL}/api/ubicacion/departamentos?provincia_id=${provinciaId}`) .then(res => res.json()) .then(setDepartamentos); } else { @@ -131,7 +131,7 @@ export default function Publicar() { useEffect(() => { if (departamentoId) { - fetch(`${API_URL}api/ubicacion/localidades?departamento_id=${departamentoId}`) + fetch(`${API_URL}/api/ubicacion/localidades?departamento_id=${departamentoId}`) .then(res => res.json()) .then(setLocalidades); } else { @@ -141,7 +141,7 @@ export default function Publicar() { }, [departamentoId]); useEffect(() => { - fetch(`${API_URL}api/etiquetas`) + fetch(`${API_URL}/api/etiquetas`) .then(res => res.json()) .then(data => { const mapped = data.map(e => ({ label: e.nombre, id: e.id })); @@ -177,7 +177,7 @@ export default function Publicar() { formData.append("imagenes", img); }); - const resImagenes = await fetch(`${API_URL}subir-imagenes`, { + const resImagenes = await fetch(`${API_URL}/subir-imagenes`, { method: "POST", body: formData, }); @@ -209,7 +209,7 @@ export default function Publicar() { } const token = await user.getIdToken(); - const res = await fetch(`${API_URL}publicaciones`, { + const res = await fetch(`${API_URL}/publicaciones`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/components/Reportes/Reportes.jsx b/src/components/Reportes/Reportes.jsx index 1472a70..55fd323 100644 --- a/src/components/Reportes/Reportes.jsx +++ b/src/components/Reportes/Reportes.jsx @@ -28,7 +28,7 @@ export default function ReporteForm({ idPublicacion, idUsuario, onClose }) { try { const token = await user.getIdToken(); - const response = await fetch(`${API_URL}reportes`, { + const response = await fetch(`${API_URL}/reportes`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/components/buscar/Buscar.jsx b/src/components/buscar/Buscar.jsx index 332c233..f63852b 100644 --- a/src/components/buscar/Buscar.jsx +++ b/src/components/buscar/Buscar.jsx @@ -40,7 +40,7 @@ const Buscar = () => { useEffect(() => { // Obtener etiquetas desde backend - fetch(`${API_URL}api/etiquetas`) + fetch(`${API_URL}/api/etiquetas`) .then(res => res.json()) .then(data => { const mapped = data.map(e => ({ label: e.nombre, id: e.id })); @@ -68,7 +68,7 @@ const Buscar = () => { const cargarPublicaciones = async () => { try { setLoading(true); // ✅ activa loading - const res = await fetch(`${API_URL}publicaciones`); + const res = await fetch(`${API_URL}/publicaciones`); const data = await res.json(); setPublicaciones(data); } catch (error) { diff --git a/src/components/login/GoogleLogin.jsx b/src/components/login/GoogleLogin.jsx index f490a7f..ac87a8d 100644 --- a/src/components/login/GoogleLogin.jsx +++ b/src/components/login/GoogleLogin.jsx @@ -24,7 +24,7 @@ function Login() { localStorage.setItem("userEmail", user.email); localStorage.setItem("token", idToken); - const response = await fetch(`${API_URL}api/login`, { + const response = await fetch(`${API_URL}/api/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: idToken }), diff --git a/src/components/publicacion/Publicacion.jsx b/src/components/publicacion/Publicacion.jsx index 4e0e069..1cb9fc5 100644 --- a/src/components/publicacion/Publicacion.jsx +++ b/src/components/publicacion/Publicacion.jsx @@ -122,7 +122,7 @@ export default function Publicacion() { // Obtener publicación useEffect(() => { axios - .get(`${API_URL}publicaciones/${id}`) + .get(`${API_URL}/publicaciones/${id}`) .then((res) => { setPublicacion(res.data); setLoading(false); @@ -137,7 +137,7 @@ export default function Publicacion() { useEffect(() => { if (publicacion?.id_usuario) { axios - .get(`${API_URL}usuario/${publicacion.id_usuario}`) + .get(`${API_URL}/usuario/${publicacion.id_usuario}`) .then((res) => setUsuario(res.data)) .catch((err) => console.error("Error al obtener el usuario:", err)); } @@ -147,7 +147,7 @@ export default function Publicacion() { if (!id) return; axios - .get(`${API_URL}comentarios/publicacion/${id}`) + .get(`${API_URL}/comentarios/publicacion/${id}`) .then(async (res) => { const comentarios = res.data; setComentarios(comentarios); @@ -160,7 +160,7 @@ export default function Publicacion() { await Promise.all( idsUnicos.map(async (idUsuario) => { try { - const res = await axios.get(`${API_URL}usuario/${idUsuario}`); + const res = await axios.get(`${API_URL}/usuario/${idUsuario}`); usuariosMap[idUsuario] = res.data; } catch (err) { console.error(`Error al obtener usuario ${idUsuario}`, err); @@ -221,7 +221,7 @@ export default function Publicacion() { const descargarPDF = async (idPublicacion) => { try { - const response = await axios.get(`${API_URL}pdf/${idPublicacion}`, { + const response = await axios.get(`${API_URL}/pdf/${idPublicacion}`, { responseType: "blob", }); @@ -272,7 +272,7 @@ export default function Publicacion() { try { const token = await user.getIdToken(); - const res = await fetch(`${API_URL}comentarios`, { + const res = await fetch(`${API_URL}/comentarios`, { method: "POST", headers: { "Content-Type": "application/json", @@ -291,7 +291,7 @@ export default function Publicacion() { setNuevoComentario(""); // Recargar comentarios - const comentariosRes = await fetch(`${API_URL}comentarios/publicacion/${id}`); + const comentariosRes = await fetch(`${API_URL}/comentarios/publicacion/${id}`); const comentariosData = await comentariosRes.json(); setComentarios(comentariosData); } else { diff --git a/src/services/perfilService.js b/src/services/perfilService.js index c72478b..b710518 100644 --- a/src/services/perfilService.js +++ b/src/services/perfilService.js @@ -93,7 +93,7 @@ export function fetchUsuarioActual() { try { const token = await user.getIdToken(); - const res = await fetch(`${API_URL}usuarios/me`, { + const res = await fetch(`${API_URL}/usuarios/me`, { headers: { Authorization: `Bearer ${token}` } From d985a175b341f92b33a5382ffa49cdfb8bbd23cc Mon Sep 17 00:00:00 2001 From: Matias Date: Sun, 28 Sep 2025 20:31:16 -0300 Subject: [PATCH 09/96] Primera version del mapa interactivo --- .../Mapa Interactivo/MapaInteractivo.jsx | 76 +++++++++++++++++++ src/router/RouterApp.jsx | 2 + 2 files changed, 78 insertions(+) create mode 100644 src/components/Mapa Interactivo/MapaInteractivo.jsx diff --git a/src/components/Mapa Interactivo/MapaInteractivo.jsx b/src/components/Mapa Interactivo/MapaInteractivo.jsx new file mode 100644 index 0000000..a8471f3 --- /dev/null +++ b/src/components/Mapa Interactivo/MapaInteractivo.jsx @@ -0,0 +1,76 @@ + +// src/components/MapaInteractivo.jsx +import React, { useEffect, useState } from "react"; +import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet"; +import "leaflet/dist/leaflet.css"; +import L from "leaflet"; + +// Configurar icono personalizado (porque Leaflet a veces no carga el default en React) +const icon = new L.Icon({ + iconUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png", + iconRetinaUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon-2x.png", + shadowUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png", + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); + +const MapaInteractivo = () => { + const [publicaciones, setPublicaciones] = useState([]); + + useEffect(() => { + // Llamada al backend para traer publicaciones + const fetchData = async () => { + try { + const res = await fetch("http://localhost:5000/publicaciones/mapa"); + const data = await res.json(); + setPublicaciones(data); + } catch (error) { + console.error("Error al traer publicaciones:", error); + } + }; + + fetchData(); + }, []); + + return ( +
+ + + {publicaciones + .filter(pub => + Array.isArray(pub.coordenadas) && + pub.coordenadas.length === 2 && + !isNaN(parseFloat(pub.coordenadas[0])) && + !isNaN(parseFloat(pub.coordenadas[1])) + ) + .map((pub, idx) => { + const lat = parseFloat(pub.coordenadas[0]); + const lng = parseFloat(pub.coordenadas[1]); + // Se asume que pub.imagenes es un array de URLs, se toma la primera imagen + const imagenUrl = pub.imagen_principal; + return ( + + + {pub.titulo}
+ {pub.descripcion}
+ {imagenUrl && ( + {pub.titulo} + )} +
+
+ ); + })} +
+
+ ); +}; + +export default MapaInteractivo; + + diff --git a/src/router/RouterApp.jsx b/src/router/RouterApp.jsx index f900653..fe34556 100644 --- a/src/router/RouterApp.jsx +++ b/src/router/RouterApp.jsx @@ -20,6 +20,7 @@ import ComentariosAdmin from "../components/PanelAdmin/sections/ComentariosAdmin import UbicacionesAdmin from "../components/PanelAdmin/sections/UbicacionesAdmin.jsx"; import EtiquetasAdmin from "../components/PanelAdmin/sections/EtiquetasAdmin.jsx"; import ReportesAdmin from "../components/PanelAdmin/sections/ReportesAdmin.jsx"; +import MapaInteractivo from '../components/Mapa Interactivo/MapaInteractivo.jsx'; function RouterApp() { return ( @@ -44,6 +45,7 @@ function RouterApp() { } /> } /> } /> + } /> {/*Rutas admin */} From 614cf48478f0321a8622c99e556bfc2cdba62a3c Mon Sep 17 00:00:00 2001 From: Matias Date: Sun, 28 Sep 2025 23:25:40 -0300 Subject: [PATCH 10/96] Version actualizada --- .../Mapa Interactivo/MapaInteractivo.jsx | 113 ++++++++++++++++-- 1 file changed, 104 insertions(+), 9 deletions(-) diff --git a/src/components/Mapa Interactivo/MapaInteractivo.jsx b/src/components/Mapa Interactivo/MapaInteractivo.jsx index a8471f3..8e1fa83 100644 --- a/src/components/Mapa Interactivo/MapaInteractivo.jsx +++ b/src/components/Mapa Interactivo/MapaInteractivo.jsx @@ -5,10 +5,38 @@ import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet"; import "leaflet/dist/leaflet.css"; import L from "leaflet"; -// Configurar icono personalizado (porque Leaflet a veces no carga el default en React) -const icon = new L.Icon({ - iconUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png", - iconRetinaUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon-2x.png", +// Iconos por categoría +const markerIcons = { + "Adopción": new L.Icon({ + iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-blue.png", + iconRetinaUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png", + shadowUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png", + iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41], + }), + "Búsqueda": new L.Icon({ + iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-green.png", + iconRetinaUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png", + shadowUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png", + iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41], + }), + "Encuentro": new L.Icon({ + iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-orange.png", + iconRetinaUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-orange.png", + shadowUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png", + iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41], + }), + "Estado Crítico": new L.Icon({ + iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-violet.png", + iconRetinaUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-violet.png", + shadowUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png", + iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41], + }), +}; + +// Icono para refugios (color rojo) +const shelterIcon = new L.Icon({ + iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-red.png", + iconRetinaUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png", shadowUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png", iconSize: [25, 41], iconAnchor: [12, 41], @@ -18,6 +46,8 @@ const icon = new L.Icon({ const MapaInteractivo = () => { const [publicaciones, setPublicaciones] = useState([]); + const [refugios, setRefugios] = useState([]); + const [categoria, setCategoria] = useState(""); useEffect(() => { // Llamada al backend para traer publicaciones @@ -30,20 +60,63 @@ const MapaInteractivo = () => { console.error("Error al traer publicaciones:", error); } }; - fetchData(); + + // Llamada al backend Flask para traer refugios + const fetchRefugios = async () => { + try { + const query = '[out:json][timeout:25];node[amenity=animal_shelter](-55,-73,-21,-53);out body;'; + const res = await fetch("http://localhost:5000/api/refugios", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query }) + }); + const data = await res.json(); + if (data && data.elements) { + setRefugios(data.elements); + } + } catch (error) { + console.error("Error al traer refugios:", error); + } + }; + fetchRefugios(); }, []); + // Obtener categorías únicas de las publicaciones + const categoriasUnicas = Array.from(new Set(publicaciones.map(p => p.categoria))).filter(Boolean); + return (
+
+
+ + +
+
+ Refugio + Marcadores rojos: Refugios de mascotas +
+
+ {/* Marcadores de publicaciones */} {publicaciones .filter(pub => + (categoria === "" || pub.categoria === categoria) && Array.isArray(pub.coordenadas) && pub.coordenadas.length === 2 && !isNaN(parseFloat(pub.coordenadas[0])) && @@ -52,20 +125,42 @@ const MapaInteractivo = () => { .map((pub, idx) => { const lat = parseFloat(pub.coordenadas[0]); const lng = parseFloat(pub.coordenadas[1]); - // Se asume que pub.imagenes es un array de URLs, se toma la primera imagen const imagenUrl = pub.imagen_principal; + const iconCat = markerIcons[pub.categoria] || markerIcons["Adopción"]; return ( - + - {pub.titulo}
+ + {pub.titulo} +
{pub.descripcion}
{imagenUrl && ( - {pub.titulo} + + {pub.titulo} + )} +
Categoría: {pub.categoria}
); })} + {/* Marcadores de refugios */} + {refugios.map((ref, idx) => ( + + + {ref.tags && ref.tags.name ? ref.tags.name : "Refugio de mascotas"}
+ {ref.tags && ref.tags.phone && (Tel: {ref.tags.phone}
)} + {ref.tags && ref.tags.website && (Sitio web)} +
+
+ ))}
); From 4faa88bedeef7df384cdf802ede04a7a7079ea5e Mon Sep 17 00:00:00 2001 From: Matias Date: Sun, 9 Nov 2025 12:36:46 -0300 Subject: [PATCH 11/96] =?UTF-8?q?Modificaciones=20en=20los=20titulos=20de?= =?UTF-8?q?=20las=20categor=C3=ADas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Publicar/Publicar.jsx | 2 +- src/components/Reportes/Reportes.jsx | 2 +- src/components/buscar/Buscar.jsx | 8 ++++---- src/layouts/MainLayout.jsx | 2 -- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/components/Publicar/Publicar.jsx b/src/components/Publicar/Publicar.jsx index 1bc62d7..f22f9fd 100644 --- a/src/components/Publicar/Publicar.jsx +++ b/src/components/Publicar/Publicar.jsx @@ -252,7 +252,7 @@ export default function Publicar() { sx={{ my: 2, gap: 1, flexWrap: 'wrap' }} type="single" > - {["Adopción", "Búsqueda", "Encuentro", "Estado Crítico"].map(opcion => ( + {["¡Busco un hogar!", "¡Me perdí!", "¡Me encontraron!", "¡Necesito ayuda urgente!"].map(opcion => ( + + )} @@ -256,6 +280,9 @@ const Buscar = () => {

{pub.titulo}

+

+ {new Date(pub.fecha_creacion).toLocaleDateString('es-AR')} +

📍 {pub.localidad}

{pub.categoria} diff --git a/src/services/busquedaService.js b/src/services/busquedaService.js index 0bec850..1e10200 100644 --- a/src/services/busquedaService.js +++ b/src/services/busquedaService.js @@ -18,7 +18,7 @@ export async function fetchPublicacionesFiltradas(params) { const data = await response.json(); // Convertimos cada objeto recibido en una instancia de Publicacion - return data.map(pub => new Publicacion(pub)); + return data; } // Opcional: función para obtener una publicación por ID si la necesitas From 3e452a8c558a87149c4bb081cbfb04aca88c02c0 Mon Sep 17 00:00:00 2001 From: "joaquin.l.terzano@gmail.com" Date: Sat, 15 Nov 2025 16:49:54 -0300 Subject: [PATCH 13/96] pelotudes 1 --- src/router/RouterApp.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/router/RouterApp.jsx b/src/router/RouterApp.jsx index fe34556..f9c106e 100644 --- a/src/router/RouterApp.jsx +++ b/src/router/RouterApp.jsx @@ -10,7 +10,7 @@ import MainLayout from '../layouts/MainLayout'; import Perfil from '../components/Perfil/Perfil'; import Editar from '../components/Editar/editar.jsx'; import ConfigPerfil from '../components/Perfil/configPerfil.jsx'; -import Notificaciones from '../components/Notificaciones/notificaciones.jsx'; +import Notificaciones from '../components/Notificaciones/Notificaciones.jsx'; import PanelAdmin from '../components/PanelAdmin/PanelAdmin.jsx'; import HomeAdmin from "../components/PanelAdmin/sections/HomeAdmin.jsx"; import UsuariosAdmin from "../components/PanelAdmin/sections/UsuariosAdmin.jsx"; From dc0ac14bfb6c9f5a0d14d5fa60a2f6f569bd87c1 Mon Sep 17 00:00:00 2001 From: "joaquin.l.terzano@gmail.com" Date: Sat, 15 Nov 2025 16:52:40 -0300 Subject: [PATCH 14/96] pelotudes 2 --- src/components/Perfil/perfilConfigruaciones.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Perfil/perfilConfigruaciones.jsx b/src/components/Perfil/perfilConfigruaciones.jsx index c769138..2bd36da 100644 --- a/src/components/Perfil/perfilConfigruaciones.jsx +++ b/src/components/Perfil/perfilConfigruaciones.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { actualizarUsuario, configUsuarioActual } from '../../services/perfilService'; -import './cperfilconfiguraciones.css'; +import './cperfilConfiguraciones.css'; export default function PerfilConfiguracion() { const [usuario, setUsuario] = useState(null); From d845b2bb3a9a540cefbee9d980c9812e7e832166 Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Sat, 15 Nov 2025 18:33:29 -0300 Subject: [PATCH 15/96] Cambios para poder archivar y desarchivar publicaciones. Nuevos botones en mi perfil. --- src/components/Perfil/cuserPublications.css | 52 ++++++++++++++++++ src/components/Perfil/selfPublications.jsx | 61 ++++++++++++++++++++- src/models/publicacion.js | 2 + src/services/perfilService.js | 35 ++++++++++++ src/utils/confirmservice.js | 12 ++++ 5 files changed, 160 insertions(+), 2 deletions(-) diff --git a/src/components/Perfil/cuserPublications.css b/src/components/Perfil/cuserPublications.css index ed330ae..d421cba 100644 --- a/src/components/Perfil/cuserPublications.css +++ b/src/components/Perfil/cuserPublications.css @@ -101,3 +101,55 @@ margin-top: 10px; } } + + +.badge-archivado { + background: #999; + padding: 3px 6px; + border-radius: 4px; + color: white; + font-size: 12px; +} + +.badge-bloqueado { + background: #d9534f; + padding: 3px 6px; + border-radius: 4px; + color: white; + font-size: 12px; +} + + +/* 🔵 Botón Archivar */ +.boton-archivar { + background-color: #3a7bd5; /* azul suave */ + color: white; + font-weight: bold; + padding: 10px 18px; + border: none; + border-radius: 20px; + cursor: pointer; + transition: background-color 0.3s ease; + white-space: nowrap; +} + +.boton-archivar:hover { + background-color: #316bb8; /* azul más intenso */ +} + +/* 🟢 Botón Desarchivar */ +.boton-desarchivar { + background-color: #38a169; /* verde amable */ + color: white; + font-weight: bold; + padding: 10px 18px; + border: none; + border-radius: 20px; + cursor: pointer; + transition: background-color 0.3s ease; + white-space: nowrap; +} + +.boton-desarchivar:hover { + background-color: #2f8a59; /* verde más oscuro */ +} diff --git a/src/components/Perfil/selfPublications.jsx b/src/components/Perfil/selfPublications.jsx index 062afff..89316d2 100644 --- a/src/components/Perfil/selfPublications.jsx +++ b/src/components/Perfil/selfPublications.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import './cuserPublications.css'; -import { fetchMisPublicaciones, fetchPublicacionesPorUsuario, eliminarPublicacion } from '../../services/perfilService'; +import { fetchMisPublicaciones, fetchPublicacionesPorUsuario, eliminarPublicacion, archivarPublicacion, desarchivarPublicacion } from '../../services/perfilService'; import { confirmarAccion } from '../../utils/confirmservice'; const SelfPublications = ({ userId, isOwner }) => { @@ -22,6 +22,36 @@ const SelfPublications = ({ userId, isOwner }) => { }); }; + //Archivar publicación + const handleArchivar = (id) => { + confirmarAccion({ + tipo: 'archivar', + onConfirm: async () => { + await archivarPublicacion(id); + setPublicaciones(prev => + prev.map(pub => + pub.id === id ? { ...pub, estado: 1 } : pub + ) + ); + }, + }); + }; + + const handleDesarchivar = (id) => { + confirmarAccion({ + tipo: 'desarchivar', + onConfirm: async () => { + await desarchivarPublicacion(id); + setPublicaciones(prev => + prev.map(pub => + pub.id === id ? { ...pub, estado: 0 } : pub + ) + ); + }, + }); + }; + + // Cargar publicaciones useEffect(() => { setLoading(true); @@ -76,6 +106,13 @@ const SelfPublications = ({ userId, isOwner }) => {

{pub.titulo}

+ {/* 🔹 BADGES DE ESTADO */} + {pub.estado === 1 && ( + Archivado + )} + {pub.estado === 2 && ( + Bloqueado + )} {pub.categoria}
{pub.etiquetas.map((etiqueta, idx) => ( @@ -85,7 +122,7 @@ const SelfPublications = ({ userId, isOwner }) => { ))}
- +
+ {pub.estado !== 1 && ( + + )} + + {/* 🔹 Mostrar DESARCHIVAR solo si estado === archivado */} + {pub.estado === 1 && ( + + )} )}
diff --git a/src/models/publicacion.js b/src/models/publicacion.js index f09d2f3..35f594e 100644 --- a/src/models/publicacion.js +++ b/src/models/publicacion.js @@ -6,6 +6,7 @@ export default class Publicacion { id_locacion, titulo, descripcion, + estado, categoria, etiquetas, fecha_creacion, @@ -18,6 +19,7 @@ export default class Publicacion { this.id_locacion = id_locacion; this.titulo = titulo; this.descripcion = descripcion; + this.estado = estado; this.categoria = categoria; this.etiquetas = etiquetas; this.fecha_creacion = fecha_creacion; diff --git a/src/services/perfilService.js b/src/services/perfilService.js index b710518..514c8d1 100644 --- a/src/services/perfilService.js +++ b/src/services/perfilService.js @@ -41,6 +41,41 @@ export async function eliminarPublicacion(id) { return await response.json(); // mensaje confirmando eliminación } +export async function archivarPublicacion(id) { + const response = await fetch(`${BASE_URL}/publicaciones/${id}/archivar`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + // Agrega Authorization si usas autenticación + // 'Authorization': 'Bearer TU_TOKEN_AQUI' + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Error al archivar la publicación'); + } + + return await response.json(); // mensaje confirmando eliminación +} + +export async function desarchivarPublicacion(id) { + const response = await fetch(`${BASE_URL}/publicaciones/${id}/desarchivar`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + // Agrega Authorization si usas autenticación + // 'Authorization': 'Bearer TU_TOKEN_AQUI' + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Error al archivar la publicación'); + } + + return await response.json(); // mensaje confirmando eliminación +} export function fetchMisPublicaciones() { const auth = getAuth(); diff --git a/src/utils/confirmservice.js b/src/utils/confirmservice.js index 6b99638..a5047b6 100644 --- a/src/utils/confirmservice.js +++ b/src/utils/confirmservice.js @@ -21,6 +21,10 @@ const getTitle = (tipo) => { return '¿Eliminar publicación?'; case 'usuario': return '¿Eliminar usuario?'; + case 'archivar': + return '¿Archivar publicación?'; + case 'desarchivar': + return '¿Desarchivar publicación?'; default: return '¿Confirmar acción?'; } @@ -32,6 +36,10 @@ const getMessage = (tipo) => { return '¿Estás seguro de que deseas eliminar esta publicación?'; case 'usuario': return '¿Estás seguro de que deseas eliminar este usuario?'; + case 'archivar': + return 'La publicación será archivada y no será visible para otros usuarios.'; + case 'desarchivar': + return 'La publicación volverá a estar visible para todos.'; default: return '¿Estás seguro de realizar esta acción?'; } @@ -42,6 +50,10 @@ const getConfirmText = (tipo) => { case 'publicacion': case 'usuario': return 'Eliminar'; + case 'archivar': + return 'Archivar'; + case 'desarchivar': + return 'Desarchivar'; default: return 'Confirmar'; } From 6ccf5392545e625886b15f22be4ca2f6ce8ac335 Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Sat, 15 Nov 2025 21:46:11 -0300 Subject: [PATCH 16/96] guarda el formulario, si nos vamos a otra pestania y volvemos a entrar, se guardan los datos que habiamos rellenado hasta el momento. --- src/components/Publicar/Publicar.jsx | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/components/Publicar/Publicar.jsx b/src/components/Publicar/Publicar.jsx index f22f9fd..ba06550 100644 --- a/src/components/Publicar/Publicar.jsx +++ b/src/components/Publicar/Publicar.jsx @@ -93,6 +93,47 @@ export default function Publicar() { const navigate = useNavigate(); const [cargando, setCargando] = useState(false); + useEffect(() => { + const borrador = localStorage.getItem("borrador_publicacion"); + if (borrador) { + const data = JSON.parse(borrador); + + setTitulo(data.titulo || ""); + setDescripcion(data.descripcion || ""); + setSeleccionado(data.seleccionado || ""); + setProvinciaId(data.provinciaId || ""); + setDepartamentoId(data.departamentoId || ""); + setLocalidadId(data.localidadId || ""); + setCoordenadas(data.coordenadas || { lat: -34.6, lng: -58.4 }); + setEtiquetasSeleccionadas(data.etiquetasSeleccionadas || []); + } + }, []); + + // Guardar automáticamente cuando cambia algo + useEffect(() => { + const borrador = { + titulo, + descripcion, + seleccionado, + provinciaId, + departamentoId, + localidadId, + coordenadas, + etiquetasSeleccionadas, + }; + + localStorage.setItem("borrador_publicacion", JSON.stringify(borrador)); + }, [ + titulo, + descripcion, + seleccionado, + provinciaId, + departamentoId, + localidadId, + coordenadas, + etiquetasSeleccionadas + ]); + const API_URL = import.meta.env.VITE_API_URL; const validarCampos = () => { @@ -226,6 +267,7 @@ export default function Publicar() { mensaje: 'Publicación enviada con éxito', tipo: 'success' }); + localStorage.removeItem("borrador_publicacion"); navigate(`/publicacion/${data.id_publicacion}`); } else { throw new Error(data.error || "Error en el envío"); From 5371ba1ca084b2888959c5550442777ada4e2efd Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Sat, 15 Nov 2025 23:22:46 -0300 Subject: [PATCH 17/96] validacion de imagenes y mejora en el formato para agregar y eliminar imagenes. --- src/components/Publicar/Publicar.jsx | 119 ++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 4 deletions(-) diff --git a/src/components/Publicar/Publicar.jsx b/src/components/Publicar/Publicar.jsx index ba06550..c0abc9f 100644 --- a/src/components/Publicar/Publicar.jsx +++ b/src/components/Publicar/Publicar.jsx @@ -61,6 +61,40 @@ const marcadorIcono = new L.Icon({ iconAnchor: [12, 41], }); +const MAX_IMAGENES = 5; +const MAX_SIZE_MB = 5; // Por archivo +const MAX_WIDTH = 4000; +const MAX_HEIGHT = 4000; + +const formatosPermitidos = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; + +const validarImagen = (file) => { + return new Promise((resolve, reject) => { + // Validar tipo + if (!formatosPermitidos.includes(file.type)) { + return reject("Formato no permitido. Usa JPG, JPEG, PNG o WEBP."); + } + + // Validar tamaño + if (file.size > MAX_SIZE_MB * 1024 * 1024) { + return reject(`La imagen supera los ${MAX_SIZE_MB} MB.`); + } + + // Validar dimensiones + const img = new Image(); + img.onload = () => { + if (img.width > MAX_WIDTH || img.height > MAX_HEIGHT) { + reject(`La imagen supera las dimensiones máximas (${MAX_WIDTH}x${MAX_HEIGHT}px).`); + } else { + resolve(); // Imagen válida + } + }; + img.onerror = () => reject("No se pudo leer la imagen."); + img.src = URL.createObjectURL(file); + }); +}; + + function MapaInteractivo({ lat, lng, setLatLng }) { const map = useMapEvents({ click(e) { @@ -200,9 +234,40 @@ export default function Publicar() { } }; - const handleImagenesChange = (event) => { - const files = Array.from(event.target.files); - setImagenesSeleccionadas(files); + const handleImagenesChange = async (event) => { + const archivos = Array.from(event.target.files); + + if (imagenesSeleccionadas.length + archivos.length > MAX_IMAGENES) { + mostrarAlerta({ + titulo: "Demasiadas imágenes", + mensaje: "Solo podés subir un máximo de 5 imágenes.", + tipo: "warning" + }); + return; + } + + const nuevasValidas = []; + + for (const file of archivos) { + try { + await validarImagen(file); + nuevasValidas.push(file); + } catch (err) { + mostrarAlerta({ + titulo: "Imagen inválida", + mensaje: err.toString(), + tipo: "warning" + }); + } + } + + setImagenesSeleccionadas((prev) => [...prev, ...nuevasValidas]); + + event.target.value = ""; + }; + + const eliminarImagen = (index) => { + setImagenesSeleccionadas(prev => prev.filter((_, i) => i !== index)); }; const handlePublicar = async () => { @@ -415,7 +480,53 @@ export default function Publicar() { Subir imágenes ({imagenesSeleccionadas.length}) - + + {imagenesSeleccionadas.length > 0 && ( +
+ {imagenesSeleccionadas.map((img, index) => ( +
+ preview + + +
+ ))} +
+ )} + + {imagenesSeleccionadas.length > 0 && ( {imagenesSeleccionadas.map(file => file.name).join(', ')} From 3784ebecd9458136b5c1dbb674539435a5627d22 Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Sun, 16 Nov 2025 14:14:19 -0300 Subject: [PATCH 18/96] Modificaciones para paginado, usando boton de cargar mas --- src/components/buscar/Buscar.jsx | 163 +++++++++++++++++++++---------- 1 file changed, 110 insertions(+), 53 deletions(-) diff --git a/src/components/buscar/Buscar.jsx b/src/components/buscar/Buscar.jsx index d9b2dd0..ad710cd 100644 --- a/src/components/buscar/Buscar.jsx +++ b/src/components/buscar/Buscar.jsx @@ -1,10 +1,9 @@ import React, { useState, useEffect } from 'react'; import { fetchPublicacionesFiltradas } from '../../services/busquedaService'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import './Buscar.css'; import { FormControl, FormLabel, TextField } from '@mui/material'; import Autocomplete from '@mui/material/Autocomplete'; -import { useLocation } from "react-router-dom"; const categoriasPosibles = [ { label: "¡Busco un hogar!", value: "Adopción" }, @@ -15,15 +14,18 @@ const categoriasPosibles = [ const API_URL = import.meta.env.VITE_API_URL; - - const Buscar = () => { const navigate = useNavigate(); - const [idPublicacion, setIdPublicacion] = useState(null); + const location = useLocation(); + + const [page, setPage] = useState(0); + const [hasMore, setHasMore] = useState(true); + const LIMIT = 12; const [categorias, setCategorias] = useState([]); const [publicaciones, setPublicaciones] = useState([]); - const [loading, setLoading] = useState(true); // ✅ arranca en true para mostrar "cargando" + const [loadingInitial, setLoadingInitial] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [fechaDesde, setFechaDesde] = useState(''); @@ -38,7 +40,6 @@ const Buscar = () => { const [mostrarFiltros, setMostrarFiltros] = useState(false); - const location = useLocation(); const limpiarFiltros = () => { setCategorias([]); setFechaDesde(''); @@ -46,10 +47,13 @@ const Buscar = () => { setRadioKm(''); setEtiquetasSeleccionadas([]); setTagsSeleccionados([]); - aplicarFiltros(); // vuelve a cargar todo sin filtros + aplicarFiltros(); }; + + // ===================== + // CARGA INICIAL + // ===================== useEffect(() => { - // Obtener etiquetas desde backend fetch(`${API_URL}/api/etiquetas`) .then(res => res.json()) .then(data => { @@ -58,7 +62,6 @@ const Buscar = () => { }) .catch(err => console.error("Error al obtener etiquetas:", err)); - // Obtener ubicación del navegador if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( (position) => { @@ -71,29 +74,26 @@ const Buscar = () => { ); } - // Cargar publicaciones iniciales cargarPublicaciones(); }, []); const cargarPublicaciones = async () => { try { - setLoading(true); // ✅ activa loading - const res = await fetch(`${API_URL}/publicaciones`); + setLoadingInitial(true); + const res = await fetch(`${API_URL}/publicaciones?page=0&limit=12`); const data = await res.json(); setPublicaciones(data); } catch (error) { - console.error("Error cargando publicaciones:", error); setError("Error cargando publicaciones"); } finally { - setLoading(false); + setLoadingInitial(false); } }; - // Endpoint de filtrado, ahora el back ya devuelve `localidad` directamente - const aplicarFiltros = async () => { - setLoading(true); - setError(null); - + // ===================== + // APLICAR FILTROS + // ===================== + const construirFiltros = () => { const params = {}; if (categorias.length > 0) params.categoria = categorias[0]; @@ -111,15 +111,30 @@ const Buscar = () => { params.lon = longitud; } + return params; + }; + + const aplicarFiltros = async () => { + setLoadingInitial(true); + setError(null); + + const filtros = construirFiltros(); + const filtrosActivos = Object.keys(filtros).length > 0; + try { - const publicacionesRaw = await fetchPublicacionesFiltradas(params); - console.log('Publicaciones filtradas recibidas:', publicacionesRaw); + const publicacionesRaw = await fetchPublicacionesFiltradas({ + ...filtros, + page: 0, + limit: LIMIT, + }); + setPublicaciones(publicacionesRaw); + setPage(0); + setHasMore(true); } catch (e) { - console.error("Error en fetch filtrado:", e); - setError(e.message || 'Error al obtener publicaciones'); + setError("Error al obtener publicaciones"); } finally { - setLoading(false); + setLoadingInitial(false); } }; @@ -129,17 +144,18 @@ const Buscar = () => { } else { setCategorias([value]); } - }; - // Para que aplique directamente el filtro cuando se seleccione una etiqueta. + // ===================== + // TAGS INICIALES DESDE URL + // ===================== useEffect(() => { const params = new URLSearchParams(location.search); const etiquetaInicial = params.get("etiqueta"); if (etiquetaInicial) { - // Buscar si esa etiqueta existe en las opciones const encontrada = etiquetasDisponibles.find(opt => opt.label === etiquetaInicial); + if (encontrada) { setEtiquetasSeleccionadas([encontrada]); setTagsSeleccionados([etiquetaInicial]); @@ -149,11 +165,54 @@ const Buscar = () => { }, [location.search, etiquetasDisponibles]); useEffect(() => { - if (tagsSeleccionados.length > 0) { - aplicarFiltros(); - } + if (tagsSeleccionados.length > 0) aplicarFiltros(); }, [tagsSeleccionados]); + // ===================== + // INFINITE SCROLL + // ===================== + const loadMore = async () => { + if (!hasMore || loadingMore) return; + + setLoadingMore(true); + + try { + const filtros = construirFiltros(); + const filtrosActivos = Object.keys(filtros).length > 0; + + let url; + + if (filtrosActivos) { + const params = new URLSearchParams({ + ...filtros, + page: page + 1, + limit: LIMIT + }); + + url = `${API_URL}/publicaciones/filtrar?${params.toString()}`; + } else { + url = `${API_URL}/publicaciones?page=${page + 1}&limit=${LIMIT}`; + } + + const res = await fetch(url); + const data = await res.json(); + + if (data.length < LIMIT) setHasMore(false); + + setPublicaciones(prev => [...prev, ...data]); + setPage(prev => prev + 1); + + } catch (e) { + console.error("Error cargando más publicaciones", e); + } finally { + setLoadingMore(false); + } + }; + + + // ===================== + // RENDER + // ===================== return (
@@ -180,11 +239,7 @@ const Buscar = () => { ))} {categorias.length > 0 && ( - )} @@ -199,10 +254,7 @@ const Buscar = () => {
- setRadioKm(e.target.value)}> @@ -234,32 +286,27 @@ const Buscar = () => { Aplicar filtros -
)} -
- {loading &&

Cargando publicaciones...

} + {loadingInitial &&

Cargando publicaciones...

} {error &&

Error: {error}

} - {!loading && !error && ( + {!loadingInitial && !error && (
    - {publicaciones.length === 0 && ( -
  • No hay publicaciones disponibles.
  • - )} + {publicaciones.length === 0 &&
  • No hay publicaciones disponibles.
  • } + {publicaciones.map(pub => { - // ✅ manejar string o array let imagenPrincipal = null; + if (Array.isArray(pub.imagenes) && pub.imagenes.length > 0) { imagenPrincipal = pub.imagenes[0]; } else if (typeof pub.imagenes === "string") { @@ -307,9 +354,19 @@ const Buscar = () => { })}
)} + + {hasMore && !loadingMore && ( + + )} + {loadingMore &&

Cargando más publicaciones...

}
); }; export default Buscar; - From c17ed763e8bdb1fbf54a65620819693305339298 Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Sun, 16 Nov 2025 14:56:50 -0300 Subject: [PATCH 19/96] Arreglo de boludeces en el mapa --- .../Mapa Interactivo/MapaInteractivo.jsx | 10 +++++----- src/components/buscar/Buscar.css | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/components/Mapa Interactivo/MapaInteractivo.jsx b/src/components/Mapa Interactivo/MapaInteractivo.jsx index 8e1fa83..1c1cb9d 100644 --- a/src/components/Mapa Interactivo/MapaInteractivo.jsx +++ b/src/components/Mapa Interactivo/MapaInteractivo.jsx @@ -7,25 +7,25 @@ import L from "leaflet"; // Iconos por categoría const markerIcons = { - "Adopción": new L.Icon({ + "¡Busco un hogar!": new L.Icon({ iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-blue.png", iconRetinaUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png", shadowUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png", iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41], }), - "Búsqueda": new L.Icon({ + "¡Me perdí!": new L.Icon({ iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-green.png", iconRetinaUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png", shadowUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png", iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41], }), - "Encuentro": new L.Icon({ + " ¡Me encontraron!": new L.Icon({ iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-orange.png", iconRetinaUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-orange.png", shadowUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png", iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41], }), - "Estado Crítico": new L.Icon({ + "¡Necesito ayuda urgente!": new L.Icon({ iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-violet.png", iconRetinaUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-violet.png", shadowUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png", @@ -126,7 +126,7 @@ const MapaInteractivo = () => { const lat = parseFloat(pub.coordenadas[0]); const lng = parseFloat(pub.coordenadas[1]); const imagenUrl = pub.imagen_principal; - const iconCat = markerIcons[pub.categoria] || markerIcons["Adopción"]; + const iconCat = markerIcons[pub.categoria] || markerIcons["¡Busco un hogar!"]; return ( diff --git a/src/components/buscar/Buscar.css b/src/components/buscar/Buscar.css index 89cf356..cb9febd 100644 --- a/src/components/buscar/Buscar.css +++ b/src/components/buscar/Buscar.css @@ -220,4 +220,19 @@ transform: translateY(-5px); .boton-aplicar-filtros:hover { background-color: #2c63c9; +} + +.boton-cargar-mas { + background-color: #4f8fd9; /* rojo */ + color: white; + border: none; + border-radius: 6px; + padding: 8px 14px; + cursor: pointer; + transition: background-color 0.2s ease; + font-weight: 600; +} + +.boton-cargar-mas:hover { + background-color: #2c63c9; } \ No newline at end of file From d94df7277ddac9c65b057846693c0ea8ce5c699b Mon Sep 17 00:00:00 2001 From: walorey Date: Sun, 16 Nov 2025 18:08:25 -0300 Subject: [PATCH 20/96] cambiar rol usuario al tocar editar, se muestra el nombre y el rol del usuario, el select muestre el rol actual, falta probar que se guarde --- src/components/PanelAdmin/ModalGeneral.jsx | 24 +- src/components/PanelAdmin/PanelAdmin.jsx | 21 +- .../PanelAdmin/sections/UsuariosAdmin.jsx | 380 ++++++++++++------ 3 files changed, 277 insertions(+), 148 deletions(-) diff --git a/src/components/PanelAdmin/ModalGeneral.jsx b/src/components/PanelAdmin/ModalGeneral.jsx index d008da8..1897a70 100644 --- a/src/components/PanelAdmin/ModalGeneral.jsx +++ b/src/components/PanelAdmin/ModalGeneral.jsx @@ -28,7 +28,6 @@ export default function ModalGeneral({ if (!open) return null; - // Si no recibimos "fields", los generamos a partir de las keys de data (todos text) const effectiveFields = Array.isArray(fields) && fields.length > 0 ? fields : (data ? Object.keys(data).map(k => ({ name: k, label: capitalize(k), type: 'text' })) : []); @@ -37,6 +36,16 @@ export default function ModalGeneral({ setFormData(prev => ({ ...prev, [name]: value })); }; + const renderOption = (opt) => { + // acepta opt = primitive (string/num) o { value, label } + if (opt && typeof opt === 'object' && ('value' in opt || 'label' in opt)) { + const val = opt.value !== undefined ? opt.value : opt.label; + const lab = opt.label !== undefined ? opt.label : String(opt.value ?? opt); + return { val, lab }; + } + return { val: opt, lab: String(opt) }; + }; + return ( {entity ? `Editar ${capitalize(entity)}` : 'Editar'} @@ -55,16 +64,21 @@ export default function ModalGeneral({ label={label} value={value} onChange={(e) => handleChange(name, e.target.value)} + renderValue={val => { + // mostrar la etiqueta legible en el select cuando options son objetos + const found = Array.isArray(options) ? options.map(renderOption).find(o => String(o.val) === String(val)) : null; + return found ? found.lab : String(val); + }} > - {Array.isArray(options) && options.map((opt) => ( - {opt} - ))} + {Array.isArray(options) && options.map((optRaw) => { + const { val, lab } = renderOption(optRaw); + return {lab}; + })} ); } - // por defecto text input return ( { - setModalConfig({ - open: true, - entity, // "usuario" | "ubicacion" | "etiqueta" - data // objeto con valores actuales - }); - }; //Datos del usuario: const [userName, setUserName] = useState(''); @@ -258,21 +250,10 @@ export default function PanelAdmin() { > {/*SOLO acá permito el scroll horizontal */} - + - {/*Modal general */} - setModalConfig({ open: false })} - onSave={(updated) => { - console.log("Guardado:", updated); - setModalConfig({ open: false }); - }} - /> diff --git a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx index 989872e..58e92c8 100644 --- a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx +++ b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx @@ -1,100 +1,70 @@ -import * as React from 'react'; -import { Link, useOutletContext } from 'react-router-dom'; -import { useState, useEffect, useCallback, useMemo } from 'react'; -import Toolbar from '@mui/material/Toolbar'; -import Box from '@mui/material/Box'; -import Grid from '@mui/material/Grid'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Tooltip from '@mui/material/Tooltip'; -import IconButton from '@mui/material/IconButton'; -import SearchIcon from '@mui/icons-material/Search'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import { DataGrid } from '@mui/x-data-grid'; +import * as React from "react"; +import { Link } from "react-router-dom"; +import { useState, useEffect, useCallback, useMemo } from "react"; +import Toolbar from "@mui/material/Toolbar"; +import Box from "@mui/material/Box"; +import Grid from "@mui/material/Grid"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; +import IconButton from "@mui/material/IconButton"; +import SearchIcon from "@mui/icons-material/Search"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { DataGrid } from "@mui/x-data-grid"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + MenuItem, + FormControl, + InputLabel, + Select, +} from "@mui/material"; const API_URL = import.meta.env.VITE_API_URL; export default function UsuariosAdmin() { + const [roles, setRoles] = useState([]); const [rows, setRows] = useState([]); const [rowCount, setRowCount] = useState(0); const [loading, setLoading] = useState(false); const [search, setSearch] = useState(""); - const [page, setPage] = useState(0); // DataGrid usa 0-based + const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(10); - const { openModal } = useOutletContext(); // <-- hook de Outlet - - const columns = useMemo(() => [ - { field: 'id', headerName: 'ID', width: 70 }, - { field: 'nombre', headerName: 'Nombre', width: 130 }, - { field: 'email', headerName: 'Email', width: 200 }, - { field: 'rol', headerName: 'Rol', width: 90 }, - { field: 'fecha_registro', headerName: 'Fecha de registro', width: 130 }, - { - field: 'acciones', - headerName: 'Acciones', - flex: 1, - minWidth: 350, - sortable: false, - filterable: false, - renderCell: (params) => { - const row = params.row; - return ( - <> - - - - - - ); - }, - }, - ], [openModal]); + // modal local + const [open, setOpen] = useState(false); + const [usuarioSeleccionado, setUsuarioSeleccionado] = useState(null); + // Cargar roles al inicio + useEffect(() => { + const fetchRoles = async () => { + try { + const res = await fetch(`${API_URL}/api/roles`); + const data = await res.json(); + // esperar data sea array o { roles: [...] } + const r = Array.isArray(data) ? data : data.roles || []; + setRoles(r); + } catch (error) { + console.error("Error cargando roles:", error); + } + }; + fetchRoles(); + }, []); + + // Traer usuarios const fetchUsuarios = useCallback(async () => { setLoading(true); try { const response = await fetch( - `${API_URL}/api/usuarios?page=${page + 1}&per_page=${pageSize}&search=${encodeURIComponent(search)}` + `${API_URL}/api/usuarios?page=${page + 1}&per_page=${pageSize}&search=${encodeURIComponent( + search + )}` ); const data = await response.json(); - setRows(data.usuarios); - setRowCount(data.total); + setRows(data.usuarios || []); + setRowCount(data.total || 0); } catch (error) { console.error("Error cargando usuarios:", error); } finally { @@ -106,53 +76,217 @@ export default function UsuariosAdmin() { const timeout = setTimeout(() => { setPage(0); fetchUsuarios(); - }, 500); + }, 300); return () => clearTimeout(timeout); }, [search, page, pageSize, fetchUsuarios]); + // Abrir modal para editar usuario + const handleEditUsuario = (row) => { + // 1) Intentar obtener role_id + let roleId = row.role_id; + + // 2) Si no viene role_id, lo buscamos por nombre del rol + if (!roleId && roles.length > 0) { + const found = roles.find( + (r) => r.nombre.toLowerCase() === row.rol?.toLowerCase() + ); + if (found) roleId = found.id; + } + + // 3) Fallback por si no se encuentra nada + if (!roleId && roles.length > 0) { + roleId = roles[0].id; + } + + setUsuarioSeleccionado({ ...row, role_id: roleId }); + setOpen(true); + }; + + const handleGuardar = async () => { + try { + const res = await fetch(`${API_URL}/api/admin/usuario/${usuarioSeleccionado.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nombre: usuarioSeleccionado.nombre, + role_id: usuarioSeleccionado.role_id, + }), + }); + + if (!res.ok) throw new Error("Error actualizando usuario"); + + const data = await res.json(); + const actualizado = data.usuario || data; // para cubrir ambas respuestas + + // Actualizar tabla local + setRows((prev) => + prev.map((u) => (u.id === actualizado.id ? { ...u, ...actualizado } : u)) + ); + + setOpen(false); + } catch (error) { + console.error("Error al guardar:", error); + } + }; + + + + const columns = useMemo( + () => [ + { field: "id", headerName: "ID", width: 70 }, + { field: "nombre", headerName: "Nombre", width: 130 }, + { field: "email", headerName: "Email", width: 200 }, + { field: "rol", headerName: "Rol", width: 90 }, + { field: "fecha_registro", headerName: "Fecha de registro", width: 130 }, + { + field: "acciones", + headerName: "Acciones", + flex: 1, + minWidth: 300, + sortable: false, + filterable: false, + renderCell: (params) => { + const row = params.row; + return ( + <> + + + + + + ); + }, + }, + ], + [roles] + ); + return ( - - - - - - - - setSearch(e.target.value)} - InputProps={{ disableUnderline: true, sx: { fontSize: 'default' } }} - variant="standard" - /> + <> + + + + + + + + setSearch(e.target.value)} + InputProps={{ + disableUnderline: true, + sx: { fontSize: "default" }, + }} + variant="standard" + /> + + + + + fetchUsuarios()}> + + + + - - - - fetchUsuarios()}> - - - - - - - - setPage(newPage)} - onPageSizeChange={(newPageSize) => setPageSize(newPageSize)} - pageSizeOptions={[5, 10, 20]} - autoHeight - loading={loading} - sx={{ border: 0 }} - /> - + + + setPage(newPage)} + onPageSizeChange={(newPageSize) => setPageSize(newPageSize)} + pageSizeOptions={[5, 10, 20]} + autoHeight + loading={loading} + sx={{ border: 0 }} + /> + + + {/* Modal para editar usuario */} + setOpen(false)} fullWidth maxWidth="sm"> + Editar Usuario + + + setUsuarioSeleccionado({ ...usuarioSeleccionado, nombre: e.target.value }) + } + fullWidth + /> + + + Rol + + + + + + + + + + ); } From 841aa624742b9f0416e6bd2cc4a1041de6ef366b Mon Sep 17 00:00:00 2001 From: walorey Date: Sun, 16 Nov 2025 18:35:57 -0300 Subject: [PATCH 21/96] editar usuario desde admin --- .../PanelAdmin/sections/UsuariosAdmin.jsx | 56 +++++++++---------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx index 58e92c8..7848d9e 100644 --- a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx +++ b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx @@ -33,17 +33,15 @@ export default function UsuariosAdmin() { const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(10); - // modal local const [open, setOpen] = useState(false); const [usuarioSeleccionado, setUsuarioSeleccionado] = useState(null); - // Cargar roles al inicio + // Cargar roles useEffect(() => { const fetchRoles = async () => { try { const res = await fetch(`${API_URL}/api/roles`); const data = await res.json(); - // esperar data sea array o { roles: [...] } const r = Array.isArray(data) ? data : data.roles || []; setRoles(r); } catch (error) { @@ -80,28 +78,28 @@ export default function UsuariosAdmin() { return () => clearTimeout(timeout); }, [search, page, pageSize, fetchUsuarios]); - // Abrir modal para editar usuario + // Abrir modal const handleEditUsuario = (row) => { - // 1) Intentar obtener role_id - let roleId = row.role_id; + let roleId = row.role_id; // puede venir del backend - // 2) Si no viene role_id, lo buscamos por nombre del rol + // intentar mapear si solo vino "rol" if (!roleId && roles.length > 0) { const found = roles.find( - (r) => r.nombre.toLowerCase() === row.rol?.toLowerCase() + (r) => r.nombre?.toLowerCase() === row.rol?.toLowerCase() ); if (found) roleId = found.id; } - // 3) Fallback por si no se encuentra nada + // fallback if (!roleId && roles.length > 0) { roleId = roles[0].id; } - setUsuarioSeleccionado({ ...row, role_id: roleId }); + setUsuarioSeleccionado({ ...row, role_id: Number(roleId) }); setOpen(true); }; + // Guardar cambios const handleGuardar = async () => { try { const res = await fetch(`${API_URL}/api/admin/usuario/${usuarioSeleccionado.id}`, { @@ -109,16 +107,16 @@ export default function UsuariosAdmin() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nombre: usuarioSeleccionado.nombre, - role_id: usuarioSeleccionado.role_id, + role_id: Number(usuarioSeleccionado.role_id), }), }); if (!res.ok) throw new Error("Error actualizando usuario"); const data = await res.json(); - const actualizado = data.usuario || data; // para cubrir ambas respuestas + const actualizado = data.usuario || data; - // Actualizar tabla local + // Actualizar tabla sin recargar setRows((prev) => prev.map((u) => (u.id === actualizado.id ? { ...u, ...actualizado } : u)) ); @@ -129,15 +127,13 @@ export default function UsuariosAdmin() { } }; - - const columns = useMemo( () => [ { field: "id", headerName: "ID", width: 70 }, { field: "nombre", headerName: "Nombre", width: 130 }, { field: "email", headerName: "Email", width: 200 }, - { field: "rol", headerName: "Rol", width: 90 }, - { field: "fecha_registro", headerName: "Fecha de registro", width: 130 }, + { field: "rol", headerName: "Rol", width: 130 }, + { field: "fecha_registro", headerName: "Fecha", width: 130 }, { field: "acciones", headerName: "Acciones", @@ -153,12 +149,13 @@ export default function UsuariosAdmin() { variant="outlined" size="small" sx={{ mr: 1 }} - target="_blank" component={Link} to={`/perfil/${row.slug}`} + target="_blank" > Ver + + + - + fetchUsuarios()}> @@ -244,15 +239,19 @@ export default function UsuariosAdmin() { /> - {/* Modal para editar usuario */} + {/* MODAL */} setOpen(false)} fullWidth maxWidth="sm"> Editar Usuario + - setUsuarioSeleccionado({ ...usuarioSeleccionado, nombre: e.target.value }) + setUsuarioSeleccionado({ + ...usuarioSeleccionado, + nombre: e.target.value, + }) } fullWidth /> @@ -269,7 +268,6 @@ export default function UsuariosAdmin() { role_id: Number(e.target.value), }) } - disabled={roles.length === 0} > {roles.map((rol) => ( From 157a62a0bd73799ceafb98e5764a7bcf54a962ed Mon Sep 17 00:00:00 2001 From: walorey Date: Sun, 16 Nov 2025 18:46:29 -0300 Subject: [PATCH 22/96] mensaje de cambios guardados en los usuarios --- .../PanelAdmin/sections/UsuariosAdmin.jsx | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx index 7848d9e..d8332c5 100644 --- a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx +++ b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx @@ -10,6 +10,7 @@ import Tooltip from "@mui/material/Tooltip"; import IconButton from "@mui/material/IconButton"; import SearchIcon from "@mui/icons-material/Search"; import RefreshIcon from "@mui/icons-material/Refresh"; +import { Snackbar, Alert } from "@mui/material"; import { DataGrid } from "@mui/x-data-grid"; import { Dialog, @@ -32,9 +33,10 @@ export default function UsuariosAdmin() { const [search, setSearch] = useState(""); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(10); - const [open, setOpen] = useState(false); const [usuarioSeleccionado, setUsuarioSeleccionado] = useState(null); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(""); // Cargar roles useEffect(() => { @@ -121,7 +123,15 @@ export default function UsuariosAdmin() { prev.map((u) => (u.id === actualizado.id ? { ...u, ...actualizado } : u)) ); + setRows((prev) => + prev.map((u) => (u.id === actualizado.id ? { ...u, ...actualizado } : u)) + ); + setOpen(false); + + setSnackbarMessage("Cambios guardados correctamente"); + setSnackbarOpen(true); + } catch (error) { console.error("Error al guardar:", error); } @@ -285,6 +295,18 @@ export default function UsuariosAdmin() { + setSnackbarOpen(false)} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + setSnackbarOpen(false)} severity="success" sx={{ width: '100%' }}> + {snackbarMessage} + + + + ); } From df9cde7320b0b5d0f04af58bff7a48aaea87d050 Mon Sep 17 00:00:00 2001 From: Joaquin Terzano <128100984+JoaquinTerzano@users.noreply.github.com> Date: Sun, 16 Nov 2025 18:56:33 -0300 Subject: [PATCH 23/96] Update API URL to use environment variable --- src/components/Mapa Interactivo/MapaInteractivo.jsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/Mapa Interactivo/MapaInteractivo.jsx b/src/components/Mapa Interactivo/MapaInteractivo.jsx index 1c1cb9d..8de84f4 100644 --- a/src/components/Mapa Interactivo/MapaInteractivo.jsx +++ b/src/components/Mapa Interactivo/MapaInteractivo.jsx @@ -5,6 +5,8 @@ import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet"; import "leaflet/dist/leaflet.css"; import L from "leaflet"; +const API_URL = import.meta.env.VITE_API_URL; + // Iconos por categoría const markerIcons = { "¡Busco un hogar!": new L.Icon({ @@ -53,7 +55,7 @@ const MapaInteractivo = () => { // Llamada al backend para traer publicaciones const fetchData = async () => { try { - const res = await fetch("http://localhost:5000/publicaciones/mapa"); + const res = await fetch(`${API_URL}/publicaciones/mapa`); const data = await res.json(); setPublicaciones(data); } catch (error) { @@ -66,7 +68,7 @@ const MapaInteractivo = () => { const fetchRefugios = async () => { try { const query = '[out:json][timeout:25];node[amenity=animal_shelter](-55,-73,-21,-53);out body;'; - const res = await fetch("http://localhost:5000/api/refugios", { + const res = await fetch(`${API_URL}/refugios`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query }) From 40c677086ec7676f035442149aed8b502cf62b3c Mon Sep 17 00:00:00 2001 From: walorey Date: Sun, 16 Nov 2025 21:47:35 -0300 Subject: [PATCH 24/96] botones segun rol/estado si es admin no se puede suspender, si esta suspendido se puede activar, si esta activo se puede suspender --- .../PanelAdmin/sections/UsuariosAdmin.jsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx index d8332c5..82344ba 100644 --- a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx +++ b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx @@ -142,7 +142,8 @@ export default function UsuariosAdmin() { { field: "id", headerName: "ID", width: 70 }, { field: "nombre", headerName: "Nombre", width: 130 }, { field: "email", headerName: "Email", width: 200 }, - { field: "rol", headerName: "Rol", width: 130 }, + { field: "rol", headerName: "Rol", width: 60 }, + { field: "estado", headerName: "Estado", width: 100}, { field: "fecha_registro", headerName: "Fecha", width: 130 }, { field: "acciones", @@ -176,15 +177,17 @@ export default function UsuariosAdmin() { Editar - + + @@ -298,6 +313,31 @@ export default function UsuariosAdmin() { + + {/* MODAL CONFIRMAR SUSPENDER/ACTIVAR */} + + setConfirmOpen(false)}> + Confirmar acción + + {accionUsuario.tipo === "suspender" && ( +

¿Seguro que quieres suspender al usuario {accionUsuario.usuario?.nombre}?

+ )} + {accionUsuario.tipo === "activar" && ( +

¿Seguro que quieres activar al usuario {accionUsuario.usuario?.nombre}?

+ )} +
+ + + + +
+ Date: Sun, 16 Nov 2025 23:08:44 -0300 Subject: [PATCH 26/96] suspension/activacion usuarios ya se puede activar y suspender usuarios, falta pulir el mensaje (cuando se suspende debe ser mas triste el aviso). aun no se si al actualizar la pagina te bloquea --- .../PanelAdmin/sections/UsuariosAdmin.jsx | 109 ++++++++++++------ 1 file changed, 76 insertions(+), 33 deletions(-) diff --git a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx index 1555e6f..7911aed 100644 --- a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx +++ b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx @@ -38,7 +38,8 @@ export default function UsuariosAdmin() { const [snackbarOpen, setSnackbarOpen] = useState(false); const [snackbarMessage, setSnackbarMessage] = useState(""); const [confirmOpen, setConfirmOpen] = useState(false); - const [accionUsuario, setAccionUsuario] = useState({ usuario: null, tipo: "" }); + const [accionUsuario, setAccionUsuario] = useState({ row: null, accion: "" }); + // Cargar roles @@ -140,21 +141,52 @@ export default function UsuariosAdmin() { } }; - // MODAL CONFIRMACIÓN SUSPENDER/ACTIVAR - const handleConfirmarAccion = () => { - console.log(`${accionUsuario.tipo} usuario:`, accionUsuario.usuario.id); - // aquí iría la llamada al backend - setConfirmOpen(false); - }; + const handleAccionUsuario = (row) => { + const accion = row.estado === "activo" ? "suspender" : "activar"; + setAccionUsuario({ row, accion }); + setConfirmOpen(true); + }; + const ejecutarAccion = async () => { + if (!accionUsuario) return; + const { row, accion } = accionUsuario; + + try { + const res = await fetch(`${API_URL}/api/admin/usuarios/${row.id}/${accion}`, { + method: "PATCH", + }); + + const data = await res.json(); + + if (!res.ok) throw new Error(data.error || "Error en la acción"); + + // Actualizar estado en la tabla + setRows((prev) => + prev.map((u) => + u.id === row.id ? { ...u, estado: data.usuario.estado } : u + ) + ); + + setSnackbarMessage(`Usuario ${accion === "suspender" ? "suspendido" : "activado"} correctamente`); + setSnackbarOpen(true); + } catch (error) { + console.error(error); + setSnackbarMessage(`Error: ${error.message}`); + setSnackbarOpen(true); + } finally { + setConfirmOpen(false); + setAccionUsuario(null); + } + }; + const columns = useMemo( () => [ { field: "id", headerName: "ID", width: 70 }, { field: "nombre", headerName: "Nombre", width: 130 }, { field: "email", headerName: "Email", width: 200 }, { field: "rol", headerName: "Rol", width: 60 }, - { field: "estado", headerName: "Estado", width: 100}, + { field: "estado", headerName: "Estado", width: 100 }, { field: "fecha_registro", headerName: "Fecha", width: 130 }, { field: "acciones", @@ -165,6 +197,8 @@ export default function UsuariosAdmin() { filterable: false, renderCell: (params) => { const row = params.row; + const isAdmin = row.rol?.toLowerCase() === "admin"; + return ( <> - + {isAdmin ? ( + + ) : ( + + )} From 4b0773dd332a7b6021b4a47ff48ab82aa446ffef Mon Sep 17 00:00:00 2001 From: walorey Date: Mon, 17 Nov 2025 06:23:43 -0300 Subject: [PATCH 27/96] alertas de suspension/activacion con color --- .../PanelAdmin/sections/UsuariosAdmin.jsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx index 7911aed..caa8528 100644 --- a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx +++ b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx @@ -37,6 +37,7 @@ export default function UsuariosAdmin() { const [usuarioSeleccionado, setUsuarioSeleccionado] = useState(null); const [snackbarOpen, setSnackbarOpen] = useState(false); const [snackbarMessage, setSnackbarMessage] = useState(""); + const [snackbarSeverity, setSnackbarSeverity] = useState("success"); const [confirmOpen, setConfirmOpen] = useState(false); const [accionUsuario, setAccionUsuario] = useState({ row: null, accion: "" }); @@ -168,7 +169,14 @@ export default function UsuariosAdmin() { ) ); - setSnackbarMessage(`Usuario ${accion === "suspender" ? "suspendido" : "activado"} correctamente`); + setSnackbarMessage( + accion === "suspender" + ? "Usuario suspendido" + : "Usuario activado" + ); + setSnackbarSeverity(accion === "suspender" ? "error" : "success"); + setSnackbarOpen(true); + setSnackbarOpen(true); } catch (error) { console.error(error); @@ -387,7 +395,11 @@ export default function UsuariosAdmin() { onClose={() => setSnackbarOpen(false)} anchorOrigin={{ vertical: "bottom", horizontal: "center" }} > - setSnackbarOpen(false)} severity="success" sx={{ width: '100%' }}> + setSnackbarOpen(false)} + severity={snackbarSeverity} + sx={{ width: '100%' }} + > {snackbarMessage} From c62c41e49723cdbf79df8a64ed8159fd73bd8f16 Mon Sep 17 00:00:00 2001 From: walorey Date: Mon, 17 Nov 2025 06:57:27 -0300 Subject: [PATCH 28/96] borrar usuario se puede borrar usuario, hay que ver si tambien se borran sus publicaciones y comentarios --- .../PanelAdmin/sections/UsuariosAdmin.jsx | 88 ++++++++++++++++--- 1 file changed, 78 insertions(+), 10 deletions(-) diff --git a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx index caa8528..9353fa0 100644 --- a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx +++ b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx @@ -40,6 +40,9 @@ export default function UsuariosAdmin() { const [snackbarSeverity, setSnackbarSeverity] = useState("success"); const [confirmOpen, setConfirmOpen] = useState(false); const [accionUsuario, setAccionUsuario] = useState({ row: null, accion: "" }); + const [borrarUsuario, setBorrarUsuario] = useState(null); + const [confirmBorrarOpen, setConfirmBorrarOpen] = useState(false); + @@ -142,6 +145,7 @@ export default function UsuariosAdmin() { } }; + //Abrir modal suspender/activar const handleAccionUsuario = (row) => { const accion = row.estado === "activo" ? "suspender" : "activar"; setAccionUsuario({ row, accion }); @@ -187,7 +191,40 @@ export default function UsuariosAdmin() { setAccionUsuario(null); } }; + + //Abrir modal eliminar + const handleBorrarUsuario = (row) => { + setBorrarUsuario(row); + setConfirmBorrarOpen(true); + }; + + const ejecutarBorrado = async () => { + if (!borrarUsuario) return; + try { + const res = await fetch(`${API_URL}/api/admin/usuarios/${borrarUsuario.id}`, { + method: "DELETE", + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Error al eliminar usuario"); + + // Actualizar tabla + setRows((prev) => prev.filter((u) => u.id !== borrarUsuario.id)); + + setSnackbarMessage(`Usuario borrado`); + setSnackbarSeverity("error"); // rojo para eliminar + setSnackbarOpen(true); + } catch (error) { + console.error(error); + setSnackbarMessage(`Error: ${error.message}`); + setSnackbarSeverity("error"); + setSnackbarOpen(true); + } finally { + setConfirmBorrarOpen(false); + setBorrarUsuario(null); + } + }; + const columns = useMemo( () => [ { field: "id", headerName: "ID", width: 70 }, @@ -195,7 +232,7 @@ export default function UsuariosAdmin() { { field: "email", headerName: "Email", width: 200 }, { field: "rol", headerName: "Rol", width: 60 }, { field: "estado", headerName: "Estado", width: 100 }, - { field: "fecha_registro", headerName: "Fecha", width: 130 }, + { field: "fecha_registro", headerName: "Fecha", width: 120 }, { field: "acciones", headerName: "Acciones", @@ -235,7 +272,7 @@ export default function UsuariosAdmin() { variant="contained" color="inherit" size="small" - sx={{ mr: 1, width: 80 }} + sx={{ mr: 1, width: 70 }} disabled > Denegado @@ -252,14 +289,27 @@ export default function UsuariosAdmin() { )} - + {!isAdmin ? ( + + ) : ( + + )} + ); }, @@ -389,6 +439,24 @@ export default function UsuariosAdmin() { + {/* Modal eliminar*/} + setConfirmBorrarOpen(false)}> + Confirmar eliminación + + {borrarUsuario && ( +

+ ¿Seguro que quieres borrar al usuario {borrarUsuario.nombre}? +

+ )} +
+ + + + +
+ Date: Mon, 17 Nov 2025 11:22:42 -0300 Subject: [PATCH 29/96] empezando con la administracion de publicaciones --- .../PanelAdmin/sections/ComentariosAdmin.jsx | 9 +- .../sections/PublicacionesAdmin.jsx | 113 +++++++++++------- 2 files changed, 75 insertions(+), 47 deletions(-) diff --git a/src/components/PanelAdmin/sections/ComentariosAdmin.jsx b/src/components/PanelAdmin/sections/ComentariosAdmin.jsx index 084e24d..ee1b8f5 100644 --- a/src/components/PanelAdmin/sections/ComentariosAdmin.jsx +++ b/src/components/PanelAdmin/sections/ComentariosAdmin.jsx @@ -41,14 +41,11 @@ export default function ComentariosAdmin() {
- - - - diff --git a/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx b/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx index 40ce98f..b1f4fa5 100644 --- a/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx +++ b/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx @@ -1,57 +1,88 @@ import * as React from 'react'; import { CssVarsProvider } from '@mui/joy/styles'; import JoyCssBaseline from '@mui/joy/CssBaseline'; - import AspectRatio from '@mui/joy/AspectRatio'; import Button from '@mui/joy/Button'; import Card from '@mui/joy/Card'; import CardContent from '@mui/joy/CardContent'; -import IconButton from '@mui/joy/IconButton'; import Typography from '@mui/joy/Typography'; -import BookmarkAdd from '@mui/icons-material/BookmarkAddOutlined'; +import { useNavigate } from 'react-router-dom'; +import { Link } from "react-router-dom"; + +const API_URL = import.meta.env.VITE_API_URL; export default function PublicacionesAdmin() { + const [publicaciones, setPublicaciones] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const navigate = useNavigate(); + + const fetchPublicaciones = React.useCallback(async () => { + setLoading(true); + try { + const response = await fetch(`${API_URL}/publicaciones?page=0&limit=12`); + const data = await response.json(); + setPublicaciones(data || []); + } catch (error) { + console.error('Error cargando publicaciones:', error); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + fetchPublicaciones(); + }, [fetchPublicaciones]); + + if (loading) return

Cargando publicaciones...

; + return ( - -
- Yosemite National Park - April 24 to May 02, 2021 - - - -
- - - - -
- Total price: - $2,900 -
- -
-
+
+ {publicaciones.map((pub) => ( + +
+ {pub.titulo} + + {new Date(pub.fecha_creacion).toLocaleDateString()} + +
+ {pub.imagenes && pub.imagenes.length > 0 && ( + + {pub.titulo} + + )} + +
+ + +
+
+
+ ))} +
); } From 24ca601400b1424dc3ecc3421c3a423ff065c59d Mon Sep 17 00:00:00 2001 From: walorey Date: Mon, 17 Nov 2025 20:58:34 -0300 Subject: [PATCH 30/96] todas las publicaciones con datos del usuario tarda mucho en cargar, falta el paginado y el filtrado --- .../sections/PublicacionesAdmin.jsx | 107 +++++++++++++----- 1 file changed, 81 insertions(+), 26 deletions(-) diff --git a/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx b/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx index b1f4fa5..3abd922 100644 --- a/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx +++ b/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx @@ -6,24 +6,44 @@ import Button from '@mui/joy/Button'; import Card from '@mui/joy/Card'; import CardContent from '@mui/joy/CardContent'; import Typography from '@mui/joy/Typography'; -import { useNavigate } from 'react-router-dom'; +import IconButton from '@mui/joy/IconButton'; +import Sheet from '@mui/joy/Sheet'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { Link } from "react-router-dom"; +import { useCallback } from "react"; const API_URL = import.meta.env.VITE_API_URL; export default function PublicacionesAdmin() { const [publicaciones, setPublicaciones] = React.useState([]); const [loading, setLoading] = React.useState(false); - const navigate = useNavigate(); + const [expanded, setExpanded] = React.useState({}); - const fetchPublicaciones = React.useCallback(async () => { + // Traer publicaciones con usuario e imagen principal (corregido) + const fetchPublicaciones = useCallback(async () => { setLoading(true); try { - const response = await fetch(`${API_URL}/publicaciones?page=0&limit=12`); + const response = await fetch(`${API_URL}/api/admin/publicaciones`); + + if (!response.ok) { + // Lee el body (si viene) para mostrar error más claro + let errText = `HTTP ${response.status}`; + try { + const errBody = await response.json(); + errText += ` — ${errBody.error || JSON.stringify(errBody)}`; + } catch { + // no JSON en body + const txt = await response.text().catch(() => null); + if (txt) errText += ` — ${txt}`; + } + throw new Error(errText); + } + const data = await response.json(); setPublicaciones(data || []); } catch (error) { - console.error('Error cargando publicaciones:', error); + console.error("Error cargando publicaciones:", error); + // opcional: mostrar un toast/alert al usuario } finally { setLoading(false); } @@ -33,55 +53,90 @@ export default function PublicacionesAdmin() { fetchPublicaciones(); }, [fetchPublicaciones]); + const toggleExpand = (id) => { + setExpanded(prev => ({ ...prev, [id]: !prev[id] })); + }; + if (loading) return

Cargando publicaciones...

; return (
- {publicaciones.map((pub) => ( - -
+ + {publicaciones.map((pub) => { + return ( + + + {/* Título y fecha */} {pub.titulo} - {new Date(pub.fecha_creacion).toLocaleDateString()} + {new Date(pub.fecha_creacion).toLocaleDateString("es-AR")} -
- {pub.imagenes && pub.imagenes.length > 0 && ( - - {pub.titulo} - - )} - -
+ + {/* Imagen */} + {pub.imagenPrincipal && ( + + {pub.titulo} + + )} + + {/* Botón expandir */} + toggleExpand(pub.id)} + sx={{ + mt: 1, + rotate: expanded[pub.id] ? "180deg" : "0deg", + transition: "0.2s" + }} + > + + + + {/* Datos del usuario */} + {expanded[pub.id] && ( + + + Propietario: {pub.usuario?.nombre || "Sin nombre"} + + + + Email: {pub.usuario?.email || "Sin email"} + + + )} + + {/* Botones */} +
+
- - - ))} + + + ); + })} +
); From 101c8f184af9ca967cbff5e98a07546adcf131c4 Mon Sep 17 00:00:00 2001 From: walorey Date: Mon, 17 Nov 2025 22:18:43 -0300 Subject: [PATCH 31/96] pagina basico sin filtros --- .../sections/PublicacionesAdmin.jsx | 197 ++++++++++-------- 1 file changed, 109 insertions(+), 88 deletions(-) diff --git a/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx b/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx index 3abd922..e9ce6d9 100644 --- a/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx +++ b/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx @@ -4,7 +4,6 @@ import JoyCssBaseline from '@mui/joy/CssBaseline'; import AspectRatio from '@mui/joy/AspectRatio'; import Button from '@mui/joy/Button'; import Card from '@mui/joy/Card'; -import CardContent from '@mui/joy/CardContent'; import Typography from '@mui/joy/Typography'; import IconButton from '@mui/joy/IconButton'; import Sheet from '@mui/joy/Sheet'; @@ -18,32 +17,36 @@ export default function PublicacionesAdmin() { const [publicaciones, setPublicaciones] = React.useState([]); const [loading, setLoading] = React.useState(false); const [expanded, setExpanded] = React.useState({}); + const [page, setPage] = React.useState(1); + const [limit, setLimit] = React.useState(10); + const [total, setTotal] = React.useState(0); - // Traer publicaciones con usuario e imagen principal (corregido) - const fetchPublicaciones = useCallback(async () => { + const fetchPublicaciones = useCallback(async (pagina = 1, limite = 15) => { setLoading(true); try { - const response = await fetch(`${API_URL}/api/admin/publicaciones`); + const response = await fetch( + `${API_URL}/api/admin/publicaciones?page=${pagina}&limit=${limite}` + ); if (!response.ok) { - // Lee el body (si viene) para mostrar error más claro - let errText = `HTTP ${response.status}`; - try { - const errBody = await response.json(); - errText += ` — ${errBody.error || JSON.stringify(errBody)}`; - } catch { - // no JSON en body - const txt = await response.text().catch(() => null); - if (txt) errText += ` — ${txt}`; - } - throw new Error(errText); + throw new Error("Error al traer publicaciones"); } const data = await response.json(); - setPublicaciones(data || []); + + // Agregar primera imagen + const publicacionesConImagen = data.publicaciones.map((pub) => ({ + ...pub, + primeraImagen: pub.imagenes?.length > 0 ? pub.imagenes[0] : null, + })); + + setPublicaciones(publicacionesConImagen); + setTotal(data.total); + setPage(data.page); + setLimit(data.limit); + } catch (error) { console.error("Error cargando publicaciones:", error); - // opcional: mostrar un toast/alert al usuario } finally { setLoading(false); } @@ -64,80 +67,98 @@ export default function PublicacionesAdmin() {
- {publicaciones.map((pub) => { - return ( - - - {/* Título y fecha */} - {pub.titulo} - - {new Date(pub.fecha_creacion).toLocaleDateString("es-AR")} - - - {/* Imagen */} - {pub.imagenPrincipal && ( - - {pub.titulo} - - )} - - {/* Botón expandir */} - toggleExpand(pub.id)} - sx={{ - mt: 1, - rotate: expanded[pub.id] ? "180deg" : "0deg", - transition: "0.2s" - }} + {publicaciones.map((pub) => ( + + + {/* Título y fecha */} + {pub.titulo} + + {new Date(pub.fecha_creacion).toLocaleDateString("es-AR")} + + + {/* Imagen principal */} + {pub.primeraImagen && ( + + {pub.titulo} + + )} + + {/* Botón expandir */} + toggleExpand(pub.id)} + sx={{ + mt: 1, + rotate: expanded[pub.id] ? "180deg" : "0deg", + transition: "0.2s" + }} + > + + + + {/* Datos del usuario */} + {expanded[pub.id] && ( + + + Propietario: {pub.usuario?.nombre || "Sin nombre"} + + + + Email: {pub.usuario?.email || "Sin email"} + + + )} + + {/* Botones */} +
+ + + - - -
- -
- ); - })} + Borrar + +
+
+ ))} + +
+ + {/* PAGINADO */} +
+ + + Página {page} + +
+
); } From 0c928871d732ab12b2a8c57ff6b54fb44e79ca8b Mon Sep 17 00:00:00 2001 From: walorey Date: Tue, 18 Nov 2025 07:37:43 -0300 Subject: [PATCH 32/96] paginado con onda --- dependencias.txt | 1 + package-lock.json | 104 +++++----- package.json | 4 +- .../sections/PublicacionesAdmin.jsx | 182 +++++++++--------- 4 files changed, 141 insertions(+), 150 deletions(-) diff --git a/dependencias.txt b/dependencias.txt index 71e619d..cbb78ba 100644 --- a/dependencias.txt +++ b/dependencias.txt @@ -15,6 +15,7 @@ npm install @mui/joy npm install @mui/x-data-grid npm install @mui/x-charts npm install socket.io-client +npm install @mui/material @emotion/react @emotion/styled diff --git a/package-lock.json b/package-lock.json index 11e720e..c2dbbd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,10 @@ "version": "0.0.0", "dependencies": { "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.0", + "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.1.1", "@mui/joy": "^5.0.0-beta.52", - "@mui/material": "^7.1.1", + "@mui/material": "^7.3.5", "@mui/x-charts": "^8.11.0", "@mui/x-data-grid": "^8.10.2", "@popperjs/core": "^2.11.8", @@ -276,9 +276,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", - "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -441,9 +441,9 @@ "license": "MIT" }, "node_modules/@emotion/styled": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", - "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", @@ -1951,9 +1951,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.1.1.tgz", - "integrity": "sha512-yBckQs4aQ8mqukLnPC6ivIRv6guhaXi8snVl00VtyojBbm+l6VbVhyTSZ68Abcx7Ah8B+GZhrB7BOli+e+9LkQ==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.5.tgz", + "integrity": "sha512-kOLwlcDPnVz2QMhiBv0OQ8le8hTCqKM9cRXlfVPL91l3RGeOsxrIhNRsUt3Xb8wb+pTVUolW+JXKym93vRKxCw==", "license": "MIT", "funding": { "type": "opencollective", @@ -2182,22 +2182,22 @@ } }, "node_modules/@mui/material": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.1.1.tgz", - "integrity": "sha512-mTpdmdZCaHCGOH3SrYM41+XKvNL0iQfM9KlYgpSjgadXx/fEKhhvOktxm8++Xw6FFeOHoOiV+lzOI8X1rsv71A==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz", + "integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.1", - "@mui/core-downloads-tracker": "^7.1.1", - "@mui/system": "^7.1.1", - "@mui/types": "^7.4.3", - "@mui/utils": "^7.1.1", + "@babel/runtime": "^7.28.4", + "@mui/core-downloads-tracker": "^7.3.5", + "@mui/system": "^7.3.5", + "@mui/types": "^7.4.8", + "@mui/utils": "^7.3.5", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1", - "react-is": "^19.1.0", + "react-is": "^19.2.0", "react-transition-group": "^4.4.5" }, "engines": { @@ -2210,7 +2210,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^7.1.1", + "@mui/material-pigment-css": "^7.3.5", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -2231,13 +2231,13 @@ } }, "node_modules/@mui/private-theming": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.1.1.tgz", - "integrity": "sha512-M8NbLUx+armk2ZuaxBkkMk11ultnWmrPlN0Xe3jUEaBChg/mcxa5HWIWS1EE4DF36WRACaAHVAvyekWlDQf0PQ==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.5.tgz", + "integrity": "sha512-cTx584W2qrLonwhZLbEN7P5pAUu0nZblg8cLBlTrZQ4sIiw8Fbvg7GvuphQaSHxPxrCpa7FDwJKtXdbl2TSmrA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.1", - "@mui/utils": "^7.1.1", + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", "prop-types": "^15.8.1" }, "engines": { @@ -2258,13 +2258,13 @@ } }, "node_modules/@mui/styled-engine": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.1.1.tgz", - "integrity": "sha512-R2wpzmSN127j26HrCPYVQ53vvMcT5DaKLoWkrfwUYq3cYytL6TQrCH8JBH3z79B6g4nMZZVoaXrxO757AlShaw==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.5.tgz", + "integrity": "sha512-zbsZ0uYYPndFCCPp2+V3RLcAN6+fv4C8pdwRx6OS3BwDkRCN8WBehqks7hWyF3vj1kdQLIWrpdv/5Y0jHRxYXQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.1", - "@emotion/cache": "^11.13.5", + "@babel/runtime": "^7.28.4", + "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "csstype": "^3.1.3", @@ -2292,16 +2292,16 @@ } }, "node_modules/@mui/system": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.1.1.tgz", - "integrity": "sha512-Kj1uhiqnj4Zo7PDjAOghtXJtNABunWvhcRU0O7RQJ7WOxeynoH6wXPcilphV8QTFtkKaip8EiNJRiCD+B3eROA==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.5.tgz", + "integrity": "sha512-yPaf5+gY3v80HNkJcPi6WT+r9ebeM4eJzrREXPxMt7pNTV/1eahyODO4fbH3Qvd8irNxDFYn5RQ3idHW55rA6g==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.1", - "@mui/private-theming": "^7.1.1", - "@mui/styled-engine": "^7.1.1", - "@mui/types": "^7.4.3", - "@mui/utils": "^7.1.1", + "@babel/runtime": "^7.28.4", + "@mui/private-theming": "^7.3.5", + "@mui/styled-engine": "^7.3.5", + "@mui/types": "^7.4.8", + "@mui/utils": "^7.3.5", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -2332,12 +2332,12 @@ } }, "node_modules/@mui/types": { - "version": "7.4.5", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.5.tgz", - "integrity": "sha512-ZPwlAOE3e8C0piCKbaabwrqZbW4QvWz0uapVPWya7fYj6PeDkl5sSJmomT7wjOcZGPB48G/a6Ubidqreptxz4g==", + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.8.tgz", + "integrity": "sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.2" + "@babel/runtime": "^7.28.4" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -2349,17 +2349,17 @@ } }, "node_modules/@mui/utils": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.1.tgz", - "integrity": "sha512-/31y4wZqVWa0jzMnzo6JPjxwP6xXy4P3+iLbosFg/mJQowL1KIou0LC+lquWW60FKVbKz5ZUWBg2H3jausa0pw==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.5.tgz", + "integrity": "sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.2", - "@mui/types": "^7.4.5", + "@babel/runtime": "^7.28.4", + "@mui/types": "^7.4.8", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^19.1.1" + "react-is": "^19.2.0" }, "engines": { "node": ">=14.0.0" @@ -5081,9 +5081,9 @@ } }, "node_modules/react-is": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", - "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", + "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", "license": "MIT" }, "node_modules/react-leaflet": { diff --git a/package.json b/package.json index 1aa8817..bcd0f35 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,10 @@ }, "dependencies": { "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.0", + "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.1.1", "@mui/joy": "^5.0.0-beta.52", - "@mui/material": "^7.1.1", + "@mui/material": "^7.3.5", "@mui/x-charts": "^8.11.0", "@mui/x-data-grid": "^8.10.2", "@popperjs/core": "^2.11.8", diff --git a/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx b/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx index e9ce6d9..1a3d8e4 100644 --- a/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx +++ b/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx @@ -10,6 +10,8 @@ import Sheet from '@mui/joy/Sheet'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { Link } from "react-router-dom"; import { useCallback } from "react"; +import Pagination from '@mui/material/Pagination'; +import Stack from '@mui/material/Stack'; const API_URL = import.meta.env.VITE_API_URL; @@ -34,7 +36,6 @@ export default function PublicacionesAdmin() { const data = await response.json(); - // Agregar primera imagen const publicacionesConImagen = data.publicaciones.map((pub) => ({ ...pub, primeraImagen: pub.imagenes?.length > 0 ? pub.imagenes[0] : null, @@ -60,105 +61,94 @@ export default function PublicacionesAdmin() { setExpanded(prev => ({ ...prev, [id]: !prev[id] })); }; + const handlePageChange = (event, value) => { + fetchPublicaciones(value, limit); + }; + if (loading) return

Cargando publicaciones...

; return ( - - -
- - {publicaciones.map((pub) => ( - - - {/* Título y fecha */} - {pub.titulo} - - {new Date(pub.fecha_creacion).toLocaleDateString("es-AR")} - - - {/* Imagen principal */} - {pub.primeraImagen && ( - - {pub.titulo} - - )} - - {/* Botón expandir */} - toggleExpand(pub.id)} - sx={{ - mt: 1, - rotate: expanded[pub.id] ? "180deg" : "0deg", - transition: "0.2s" - }} - > - - - - {/* Datos del usuario */} - {expanded[pub.id] && ( - - - Propietario: {pub.usuario?.nombre || "Sin nombre"} - - - - Email: {pub.usuario?.email || "Sin email"} - - - )} - - {/* Botones */} -
- - - -
- -
- ))} - -
- - {/* PAGINADO */} -
- - - Página {page} - - + + + + {expanded[pub.id] && ( + + + Propietario: {pub.usuario?.nombre || "Sin nombre"} + + + Email: {pub.usuario?.email || "Sin email"} + + + )} + +
+ + +
+ + ))} +
+
+ + {/* PAGINADO fuera de CssVarsProvider para evitar conflictos */} +
+ + +
- - + ); } From 3c77425975c9a0566293122954fd7aff9bf55a01 Mon Sep 17 00:00:00 2001 From: Smartinez117 Date: Tue, 18 Nov 2025 16:21:12 -0300 Subject: [PATCH 33/96] yo soy iron man --- package-lock.json | 12 ++++---- package.json | 2 +- src/components/Navbar.jsx | 35 ++++++++++++++++++--- src/utils/listaNOt.jsx | 41 ++++++++++++++++++++++++ src/utils/socket.js | 65 +++++++++++++++++++++------------------ src/utils/toastUtil.jsx | 11 ++++++- 6 files changed, 123 insertions(+), 43 deletions(-) create mode 100644 src/utils/listaNOt.jsx diff --git a/package-lock.json b/package-lock.json index c2dbbd3..6d33e0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@mui/icons-material": "^7.1.1", + "@mui/icons-material": "^7.3.5", "@mui/joy": "^5.0.0-beta.52", "@mui/material": "^7.3.5", "@mui/x-charts": "^8.11.0", @@ -1961,12 +1961,12 @@ } }, "node_modules/@mui/icons-material": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.1.1.tgz", - "integrity": "sha512-X37+Yc8QpEnl0sYmz+WcLFy2dWgNRzbswDzLPXG7QU1XDVlP5TPp1HXjdmCupOWLL/I9m1fyhcyZl8/HPpp/Cg==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.5.tgz", + "integrity": "sha512-LciL1GLMZ+VlzyHAALSVAR22t8IST4LCXmljcUSx2NOutgO2XnxdIp8ilFbeNf9wpo0iUFbAuoQcB7h+HHIf3A==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.1" + "@babel/runtime": "^7.28.4" }, "engines": { "node": ">=14.0.0" @@ -1976,7 +1976,7 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^7.1.1", + "@mui/material": "^7.3.5", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, diff --git a/package.json b/package.json index bcd0f35..d1ec27c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@mui/icons-material": "^7.1.1", + "@mui/icons-material": "^7.3.5", "@mui/joy": "^5.0.0-beta.52", "@mui/material": "^7.3.5", "@mui/x-charts": "^8.11.0", diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 51a6dc0..1ca2b8f 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -17,6 +17,11 @@ import { getAuth, onAuthStateChanged, signOut } from 'firebase/auth'; import {socketconnection,socketnotificationlisten} from '../utils/socket'; import {Notificacion} from '../utils/toastUtil' import { Toaster } from 'react-hot-toast'; +import { socketNotificationsConnected } from '../utils/socket'; +import AddAlertIcon from '@mui/icons-material/AddAlert'; +import AlignItemsList from '../utils/listaNOt'; // import de la funcion para mostrar las notificaciones +import { registrarCallbackAgregar } from "../utils/toastUtil"; + const pages = ['Inicio', 'Publicar', 'Buscar', 'Mapa']; const settings = ['Notificaciones', 'Mi perfil', 'Configuración', 'Cerrar sesión']; @@ -24,6 +29,12 @@ const settings = ['Notificaciones', 'Mi perfil', 'Configuración', 'Cerrar sesi const Navbar = () => { const [anchorElNav, setAnchorElNav] = React.useState(null); const [anchorElUser, setAnchorElUser] = React.useState(null); + const [notificaciones, setNotificaciones] = useState([]);//lista para las notificaciones + const [open, setOpen] = useState(false);//para desplegar la lista + + function agregarNotificacion(noti) { + setNotificaciones(prev => [...prev, noti]); + } const handleOpenNavMenu = (event) => { setAnchorElNav(event.currentTarget); @@ -46,17 +57,20 @@ const Navbar = () => { const [userPhoto, setUserPhoto] = useState(''); useEffect(() => { - const auth = getAuth(); - + // registra la función que recibirá las notificaciones + registrarCallbackAgregar(agregarNotificacion); +}, []); + useEffect(() => { + const auth = getAuth(); const unsubscribe = onAuthStateChanged(auth, (user) => { if (user) { const name = localStorage.getItem("userName"); const photo = localStorage.getItem("userPhoto"); - socketconnection() //<-- aca hace el llamado al back para registrarse como user conectado - socketnotificationlisten(user.uid)//<-- aca hace uso de la funcion que escucah las notficaciones que envia el back al front + socketconnection(user) //<-- aca hace el llamado al back para registrarse como user conectado + if (!socketNotificationsConnected){socketnotificationlisten(user.uid)}//<-- aca hace uso de la funcion que escucah las notficaciones que envia el back al front if (name) setUserName(name); if (photo) setUserPhoto(photo); @@ -220,7 +234,18 @@ const Navbar = () => { - + + + setOpen(!open)}> + + + + {open && ( + + + + )} + {userPhoto ? ( diff --git a/src/utils/listaNOt.jsx b/src/utils/listaNOt.jsx new file mode 100644 index 0000000..2f2ec45 --- /dev/null +++ b/src/utils/listaNOt.jsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import Divider from '@mui/material/Divider'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import Typography from '@mui/material/Typography'; + +export default function AlignItemsList({ notificaciones }) { + const handleVerClick = (notificacion) =>{ window.location.href = `/publicacion/${notificacion.id_publicacion}`;} + return ( + + {notificaciones.map((notificacion, index) => ( + + + + {notificacion.titulo} + + } + /> + + + {index < notificaciones.length - 1 && } + + ))} + + ); +} \ No newline at end of file diff --git a/src/utils/socket.js b/src/utils/socket.js index a54330e..3049a0f 100644 --- a/src/utils/socket.js +++ b/src/utils/socket.js @@ -2,46 +2,51 @@ import { io } from "socket.io-client"; import { getAuth,onAuthStateChanged } from "firebase/auth"; import {Notificacion} from '../utils/toastUtil' +const baseURL = "https://backendconflask.onrender.com" +//const baseURL = "http://localhost:5000" +//INSTANCIAS GLOBALES DE LOS SOCKETS +export let connectionSocket = null; +export let notificacion = null; +export let socketNotificationsConnected = false; -const baseURL = "https://backendconflask.onrender.com" +//socket para registrasrse como user connected, usando un token +export function socketconnection(user) { + if (connectionSocket) return connectionSocket; + if (!user) return; + user.getIdToken().then((token) => { + + connectionSocket = io(baseURL + '/connection', { + transports: ['websocket'], + upgrade: false, + auth: { token:token } + }); + + connectionSocket.on('connect', () => { + console.log('Socket conectado'); + }); + + connectionSocket.on('connect_error', (error) => { + console.error('Error en la conexión:', error.message); + }); -//socket para registrasrse como user connected, usando un token -export function socketconnection(){ - const auth = getAuth(); - onAuthStateChanged(auth, async (user) => { - if (!user) { - console.log("Usuario no autenticado"); - return; - } - try { - const token = await user.getIdToken(); - - const connection = io(baseURL + '/connection', { - auth: { token: token } - }); - - connection.on('connect', () => { - console.log('Socket conectado'); - }); - - connection.on('connect_error', (error) => { - console.error('Error en la conexión:', error.message); - }); - return connection; // puede devolver o guardar la conexión para usarla después - } catch (error) { - console.error('Error obteniendo token:', error); - } }); -} + return connectionSocket; +} //socket para escuchar las notificaciones notificaciones export function socketnotificationlisten(useruid) { - const notificacion = io(baseURL + '/notificacion/' + useruid, {}); - + if (socketNotificationsConnected) return; // <- evita doble registro + socketNotificationsConnected = true; + + notificacion = io(baseURL + '/notificacion', { + transports: ['websocket'], + upgrade: false, + auth: {uid: useruid} + }); notificacion.on('connect', () => { console.log('Socket-DE-notificaciones conectado'); }); diff --git a/src/utils/toastUtil.jsx b/src/utils/toastUtil.jsx index 312fa36..0dcd1d9 100644 --- a/src/utils/toastUtil.jsx +++ b/src/utils/toastUtil.jsx @@ -2,15 +2,24 @@ import toast from 'react-hot-toast'; import React from 'react'; import { marcarNotificacionLeida } from '../services/notificaciones'; +let callbackAgregar = null; + +export function registrarCallbackAgregar(fn) { + callbackAgregar = fn; +} + // aca la funcion NOtificaion recibe los datos export function Notificacion(notificacion){ - + if (callbackAgregar) { + callbackAgregar(notificacion); + } const handlerbutton = (toastId,publicacion_ID) => { toast.dismiss(toastId); window.location.href = `/publicacion/${publicacion_ID}`; console.log('marcar com0 leido en el back, si llego hasta aqui entonces se puede lograr sin') // aca tendriamos que poner la funcion para marcarlo como leido a la notificacion //marcarNotificacionLeida(notificacion.id_notificacion) //funcion de marcar como leida sin probar aun + }; toast (({toastId}) =>( From 8b8df620fa306c8a3e8bc496c23487c673845a7c Mon Sep 17 00:00:00 2001 From: walorey Date: Tue, 18 Nov 2025 17:36:12 -0300 Subject: [PATCH 34/96] adminsitracion de publicaciones precario solo se puede ver y borrar, no se pueden filtrar, hay que dedicarle a arreglar las categorias --- .../sections/PublicacionesAdmin.jsx | 278 +++++++++++------- 1 file changed, 171 insertions(+), 107 deletions(-) diff --git a/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx b/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx index 1a3d8e4..93e8f9f 100644 --- a/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx +++ b/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx @@ -1,154 +1,218 @@ import * as React from 'react'; -import { CssVarsProvider } from '@mui/joy/styles'; -import JoyCssBaseline from '@mui/joy/CssBaseline'; -import AspectRatio from '@mui/joy/AspectRatio'; -import Button from '@mui/joy/Button'; -import Card from '@mui/joy/Card'; -import Typography from '@mui/joy/Typography'; -import IconButton from '@mui/joy/IconButton'; -import Sheet from '@mui/joy/Sheet'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { Link } from "react-router-dom"; -import { useCallback } from "react"; +import { styled } from '@mui/material/styles'; +import Card from '@mui/material/Card'; +import CardHeader from '@mui/material/CardHeader'; +import CardMedia from '@mui/material/CardMedia'; +import CardContent from '@mui/material/CardContent'; +import CardActions from '@mui/material/CardActions'; +import Collapse from '@mui/material/Collapse'; +import Avatar from '@mui/material/Avatar'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import Grid from '@mui/material/Grid'; +import Button from '@mui/material/Button'; import Pagination from '@mui/material/Pagination'; import Stack from '@mui/material/Stack'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import { red } from '@mui/material/colors'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material'; + +const ExpandMore = styled((props) => { + const { expand, ...other } = props; + return ; +})(({ theme, expand }) => ({ + marginLeft: 'auto', + transform: !expand ? 'rotate(0deg)' : 'rotate(180deg)', + transition: theme.transitions.create('transform', { + duration: theme.transitions.duration.shortest, + }), +})); const API_URL = import.meta.env.VITE_API_URL; +// Componente de paginación personalizado +function PaginationRounded({ count, page, onChange }) { + return ( + + + + ); +} + export default function PublicacionesAdmin() { const [publicaciones, setPublicaciones] = React.useState([]); - const [loading, setLoading] = React.useState(false); const [expanded, setExpanded] = React.useState({}); + const [confirmBorrarPubOpen, setConfirmBorrarPubOpen] = React.useState(false); + const [publicacionSeleccionada, setPublicacionSeleccionada] = React.useState(null); const [page, setPage] = React.useState(1); - const [limit, setLimit] = React.useState(10); const [total, setTotal] = React.useState(0); + const [snackbarOpen, setSnackbarOpen] = React.useState(false); + const [snackbarMessage, setSnackbarMessage] = React.useState(''); + const limit = 9; // 3 cards por fila - const fetchPublicaciones = useCallback(async (pagina = 1, limite = 15) => { - setLoading(true); + const fetchPublicaciones = async (pagina = 1) => { try { - const response = await fetch( - `${API_URL}/api/admin/publicaciones?page=${pagina}&limit=${limite}` - ); - - if (!response.ok) { - throw new Error("Error al traer publicaciones"); - } - - const data = await response.json(); - - const publicacionesConImagen = data.publicaciones.map((pub) => ({ + const res = await fetch(`${API_URL}/api/admin/publicaciones?page=${pagina}&limit=${limit}`); + const data = await res.json(); + const pubsConImagen = data.publicaciones.map(pub => ({ ...pub, - primeraImagen: pub.imagenes?.length > 0 ? pub.imagenes[0] : null, + primeraImagen: pub.imagenes?.[0] || null, })); - - setPublicaciones(publicacionesConImagen); + setPublicaciones(pubsConImagen); setTotal(data.total); setPage(data.page); - setLimit(data.limit); - } catch (error) { - console.error("Error cargando publicaciones:", error); - } finally { - setLoading(false); + console.error(error); } - }, []); + }; React.useEffect(() => { - fetchPublicaciones(); - }, [fetchPublicaciones]); + fetchPublicaciones(page); + }, [page]); - const toggleExpand = (id) => { + const handleExpandClick = (id) => { setExpanded(prev => ({ ...prev, [id]: !prev[id] })); }; + const handleBorrarPublicacionModal = (pub) => { + setPublicacionSeleccionada(pub); + setConfirmBorrarPubOpen(true); + }; + + const ejecutarBorradoPublicacion = async () => { + if (!publicacionSeleccionada) return; + try { + const res = await fetch(`${API_URL}/publicaciones/${publicacionSeleccionada.id}`, { + method: "DELETE", + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Error al eliminar publicación"); + } + + // Actualizar lista y cerrar modal + setPublicaciones(prev => prev.filter(p => p.id !== publicacionSeleccionada.id)); + setTotal(prev => prev - 1); + setConfirmBorrarPubOpen(false); + setPublicacionSeleccionada(null); + + // Mostrar snackbar de éxito + setSnackbarMessage("Publicación borrada"); + setSnackbarOpen(true); + + } catch (error) { + console.error(error); + // Mostrar snackbar de error + setSnackbarMessage(`Error al eliminar: ${error.message}`); + setSnackbarOpen(true); + } + }; + const handlePageChange = (event, value) => { - fetchPublicaciones(value, limit); + fetchPublicaciones(value); }; - if (loading) return

Cargando publicaciones...

; + const handleCloseSnackbar = () => { + setSnackbarOpen(false); + }; return ( <> - - -
- {publicaciones.map((pub) => ( - - {pub.titulo} - - {new Date(pub.fecha_creacion).toLocaleDateString("es-AR")} - - + + {publicaciones.map(pub => ( + + + {pub.usuario?.nombre?.[0] || "U"} + } + title={pub.titulo} + subheader={new Date(pub.fecha_creacion).toLocaleDateString("es-AR")} + /> {pub.primeraImagen && ( - - {pub.titulo} - + )} - - toggleExpand(pub.id)} - sx={{ - mt: 1, - rotate: expanded[pub.id] ? "180deg" : "0deg", - transition: "0.2s" - }} - > - - - - {expanded[pub.id] && ( - - - Propietario: {pub.usuario?.nombre || "Sin nombre"} - - - Email: {pub.usuario?.email || "Sin email"} - - - )} - -
+ + + Propietario: {pub.usuario?.nombre || "Sin nombre"} + Email: {pub.usuario?.email || "Sin email"} + + + -
+ handleExpandClick(pub.id)} + aria-expanded={expanded[pub.id]} + aria-label="show more" + > + + +
- ))} -
-
- - {/* PAGINADO fuera de CssVarsProvider para evitar conflictos */} -
- - - -
+ + ))} + + + {/* Paginación */} + + + {/* Modal de borrado */} + setConfirmBorrarPubOpen(false)}> + Confirmar eliminación + + {publicacionSeleccionada && ( + + ¿Seguro que quieres borrar la publicación {publicacionSeleccionada.titulo}? + + )} + + + + + + + + {/* Snackbar */} + + + {snackbarMessage} + + ); } From 47febeeb0e6855b90d08993cdc03042dd9a72c0c Mon Sep 17 00:00:00 2001 From: Matias Date: Sat, 22 Nov 2025 14:23:57 -0300 Subject: [PATCH 35/96] Ahora los usuarios baneados les sale un pop up y los admin les aparece el panel --- src/components/Navbar.jsx | 109 ++++++++++++++++++++------- src/components/login/GoogleLogin.jsx | 46 ++++++++++- 2 files changed, 125 insertions(+), 30 deletions(-) diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 1ca2b8f..3eb9b93 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -23,8 +23,6 @@ import AlignItemsList from '../utils/listaNOt'; // import de la funcion para mos import { registrarCallbackAgregar } from "../utils/toastUtil"; -const pages = ['Inicio', 'Publicar', 'Buscar', 'Mapa']; -const settings = ['Notificaciones', 'Mi perfil', 'Configuración', 'Cerrar sesión']; const Navbar = () => { const [anchorElNav, setAnchorElNav] = React.useState(null); @@ -55,37 +53,71 @@ const Navbar = () => { const navigate = useNavigate(); const [userName, setUserName] = useState(''); const [userPhoto, setUserPhoto] = useState(''); + const [isAdmin, setIsAdmin] = useState(false); useEffect(() => { // registra la función que recibirá las notificaciones registrarCallbackAgregar(agregarNotificacion); }, []); +const pages = ['Inicio', 'Publicar', 'Buscar', 'Mapa']; +const settings = ['Notificaciones', 'Mi perfil', 'Configuración', 'Cerrar sesión']; - useEffect(() => { - const auth = getAuth(); - const unsubscribe = onAuthStateChanged(auth, (user) => { - if (user) { + useEffect(() => { + const auth = getAuth(); + + const unsubscribe = onAuthStateChanged(auth, async (user) => { + if (!user) { + setUserName(''); + setUserPhoto(''); + setIsAdmin(false); + localStorage.removeItem("userName"); + localStorage.removeItem("userPhoto"); + localStorage.removeItem("isAdmin"); + navigate("/login"); + return; + } + + // 1️⃣ Cargar datos del usuario const name = localStorage.getItem("userName"); const photo = localStorage.getItem("userPhoto"); - - socketconnection(user) //<-- aca hace el llamado al back para registrarse como user conectado - if (!socketNotificationsConnected){socketnotificationlisten(user.uid)}//<-- aca hace uso de la funcion que escucah las notficaciones que envia el back al front - if (name) setUserName(name); if (photo) setUserPhoto(photo); - } else { - //console.log("🔴 No hay usuario logueado"); - setUserName(''); - setUserPhoto(''); - localStorage.removeItem("userName"); - localStorage.removeItem("userPhoto"); - navigate("/login"); - } - }); - return () => unsubscribe(); - }, [navigate]); + // 2️⃣ Determinar si es admin ANTES de renderizar navbar + try { + const token = await user.getIdToken(); + const res = await fetch(`http://localhost:5000/usuario/${user.uid}/is_admin`, { + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + }, + }); + + if (res.ok) { + const data = await res.json(); + const esAdmin = Boolean(data.is_admin); + setIsAdmin(esAdmin); + esAdmin ? localStorage.setItem("isAdmin", "true") : localStorage.removeItem("isAdmin"); + } else { + console.error("Error verificando admin:", await res.text()); + setIsAdmin(false); + } + } catch (err) { + console.error("Error admin:", err); + setIsAdmin(false); + } + + // 3️⃣ Después: sockets + socketconnection(user); + if (!socketNotificationsConnected) { + socketnotificationlisten(user.uid); + } + }); + + return () => unsubscribe(); + }, [navigate]); + const handleUserMenuClick = (setting) => { const auth = getAuth(); @@ -96,6 +128,8 @@ const Navbar = () => { .then(() => { localStorage.removeItem("userName"); localStorage.removeItem("userPhoto"); + localStorage.removeItem("isAdmin"); + setIsAdmin(false); console.log("🔒 Sesión cerrada"); navigate("/login"); }) @@ -104,11 +138,11 @@ const Navbar = () => { }); break; - case "Mi perfil": + case "Mi perfil": { const userSlug = localStorage.getItem("userSlug"); navigate(`/perfil/${userSlug}`); - break; + } case "Notificaciones": navigate("/notificaciones"); @@ -117,6 +151,10 @@ const Navbar = () => { case "Configuración": navigate("/pconfig"); break; + + case "Panel de Admin": + navigate("/admin/panel"); + break; default: console.log(`Opción no reconocida: ${setting}`); @@ -193,7 +231,6 @@ const Navbar = () => { {page} ))} -
@@ -235,7 +272,10 @@ const Navbar = () => {
- + {userName && ( + {userName} + )} + setOpen(!open)}> @@ -264,9 +304,22 @@ const Navbar = () => { transformOrigin={{ vertical: 'top', horizontal: 'right' }} > {settings.map((setting) => ( - handleUserMenuClick(setting)}> - {setting} - + setting === 'Cerrar sesión' ? ( + + {isAdmin && ( + { handleCloseUserMenu(); navigate('/admin/panel'); }}> + Panel Admin + + )} + handleUserMenuClick(setting)}> + {setting} + + + ) : ( + handleUserMenuClick(setting)}> + {setting} + + ) ))} diff --git a/src/components/login/GoogleLogin.jsx b/src/components/login/GoogleLogin.jsx index ac87a8d..09f2824 100644 --- a/src/components/login/GoogleLogin.jsx +++ b/src/components/login/GoogleLogin.jsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { auth } from '../../firebase'; import './GoogleLogin.css'; import '../../styles/global.css'; +import Swal from 'sweetalert2'; import iconoGOOGLE from '../../assets/iconoGOOGLE.svg'; @@ -15,30 +16,70 @@ function Login() { try { const customProvider = new GoogleAuthProvider(); customProvider.setCustomParameters({ prompt: 'select_account' }); + + // INTENTO DE LOGIN const result = await signInWithPopup(auth, customProvider); const user = result.user; + + // Si llega acá, el usuario NO está baneado en Firebase + const idToken = await user.getIdToken(); + // Guardamos los datos en localStorage localStorage.setItem("userName", user.displayName); localStorage.setItem("userPhoto", user.photoURL); localStorage.setItem("userEmail", user.email); localStorage.setItem("token", idToken); + // Backend login const response = await fetch(`${API_URL}/api/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: idToken }), }); + // Backend podría devolver estado de cuenta suspendida + if (response.status === 403) { + Swal.fire({ + icon: 'error', + title: 'Cuenta suspendida', + text: 'Tu cuenta ha sido baneada por un administrador.', + }); + return; + } + if (response.ok) { const data = await response.json(); - localStorage.setItem("userIdLocal", data.idLocal); // guardar id de la BD + localStorage.setItem("userIdLocal", data.idLocal); navigate('/home'); } else { - alert("Error en autenticación con backend"); + Swal.fire({ + icon: 'error', + title: 'Error de autenticación', + text: 'No se pudo completar la autenticación con el servidor.', + }); } + } catch (error) { + console.error("Error en login con Google:", error); + + // Usuario baneado en Firebase + if (error.code === "auth/user-disabled") { + Swal.fire({ + icon: 'error', + title: 'Cuenta deshabilitada', + text: 'Esta cuenta ha sido suspendida. Contactá al administrador.', + }); + return; + } + + // Otros errores + Swal.fire({ + icon: "error", + title: "Error", + text: "No se pudo iniciar sesión. Intentalo nuevamente.", + }); } }; @@ -61,3 +102,4 @@ function Login() { export default Login; + From c18b84de5fc07ee3e778fbd7d0fd631913db0c9e Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Sat, 22 Nov 2025 16:29:23 -0300 Subject: [PATCH 36/96] Paginado para usuarios en panel admin --- .../PanelAdmin/sections/UsuariosAdmin.jsx | 179 ++++++++++-------- 1 file changed, 100 insertions(+), 79 deletions(-) diff --git a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx index 9353fa0..c065a90 100644 --- a/src/components/PanelAdmin/sections/UsuariosAdmin.jsx +++ b/src/components/PanelAdmin/sections/UsuariosAdmin.jsx @@ -31,8 +31,13 @@ export default function UsuariosAdmin() { const [rowCount, setRowCount] = useState(0); const [loading, setLoading] = useState(false); const [search, setSearch] = useState(""); - const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(10); + + // CAMBIO CLAVE 1: Usar paginationModel unificado + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const [open, setOpen] = useState(false); const [usuarioSeleccionado, setUsuarioSeleccionado] = useState(null); const [snackbarOpen, setSnackbarOpen] = useState(false); @@ -43,9 +48,6 @@ export default function UsuariosAdmin() { const [borrarUsuario, setBorrarUsuario] = useState(null); const [confirmBorrarOpen, setConfirmBorrarOpen] = useState(false); - - - // Cargar roles useEffect(() => { const fetchRoles = async () => { @@ -65,46 +67,69 @@ export default function UsuariosAdmin() { const fetchUsuarios = useCallback(async () => { setLoading(true); try { + // DataGrid usa página 0-based, el backend espera 1-based (generalmente) + // Ajustamos aquí: paginationModel.page + 1 + const queryPage = paginationModel.page + 1; + const queryLimit = paginationModel.pageSize; + const response = await fetch( - `${API_URL}/api/usuarios?page=${page + 1}&per_page=${pageSize}&search=${encodeURIComponent( + `${API_URL}/api/usuarios?page=${queryPage}&per_page=${queryLimit}&search=${encodeURIComponent( search )}` ); const data = await response.json(); - setRows(data.usuarios || []); - setRowCount(data.total || 0); + + const usuarios = Array.isArray(data.usuarios) + ? data.usuarios + : data.data || data.usuarios || []; + + // Normalizar total + const totalRaw = data.total ?? data.totalCount ?? data.count ?? usuarios.length; + const total = Number(totalRaw) || 0; + + // Asegurar IDs para DataGrid + const normalized = usuarios.map((u, idx) => ({ + ...u, + id: typeof u.id !== "undefined" ? u.id : u._id ?? idx, + })); + + setRows(normalized); + setRowCount(total); + } catch (error) { console.error("Error cargando usuarios:", error); } finally { setLoading(false); } - }, [page, pageSize, search]); + }, [paginationModel, search]); // Dependencias limpias + // CAMBIO CLAVE 2: Resetear página a 0 SOLO cuando cambia el texto de búsqueda + // Esto evita el conflicto de botones bloqueados useEffect(() => { + setPaginationModel((prev) => ({ ...prev, page: 0 })); + }, [search]); + + // CAMBIO CLAVE 3: Ejecutar fetch cuando cambia la paginación o la búsqueda (con debounce implícito si se desea, o directo) + useEffect(() => { + // Pequeño timeout para no saturar si escribes rápido, igual que tenías antes const timeout = setTimeout(() => { - setPage(0); fetchUsuarios(); }, 300); return () => clearTimeout(timeout); - }, [search, page, pageSize, fetchUsuarios]); + }, [fetchUsuarios]); // fetchUsuarios ya depende de paginationModel y search // Abrir modal EDICIÓN DE USUARIO const handleEditUsuario = (row) => { - let roleId = row.role_id; // puede venir del backend - - // intentar mapear si solo vino "rol" + let roleId = row.role_id; if (!roleId && roles.length > 0) { const found = roles.find( (r) => r.nombre?.toLowerCase() === row.rol?.toLowerCase() ); if (found) roleId = found.id; } - - // fallback if (!roleId && roles.length > 0) { roleId = roles[0].id; } - setUsuarioSeleccionado({ ...row, role_id: Number(roleId) }); setOpen(true); }; @@ -112,61 +137,61 @@ export default function UsuariosAdmin() { // Guardar cambios const handleGuardar = async () => { try { - const res = await fetch(`${API_URL}/api/admin/usuario/${usuarioSeleccionado.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - nombre: usuarioSeleccionado.nombre, - role_id: Number(usuarioSeleccionado.role_id), - }), - }); + const res = await fetch( + `${API_URL}/api/admin/usuario/${usuarioSeleccionado.id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nombre: usuarioSeleccionado.nombre, + role_id: Number(usuarioSeleccionado.role_id), + }), + } + ); if (!res.ok) throw new Error("Error actualizando usuario"); - const data = await res.json(); const actualizado = data.usuario || data; - // Actualizar tabla sin recargar + // Actualizar localmente setRows((prev) => prev.map((u) => (u.id === actualizado.id ? { ...u, ...actualizado } : u)) ); - - setRows((prev) => - prev.map((u) => (u.id === actualizado.id ? { ...u, ...actualizado } : u)) - ); - setOpen(false); - setSnackbarMessage("Cambios guardados correctamente"); + setSnackbarSeverity("success"); setSnackbarOpen(true); + + // Opcional: recargar datos reales + fetchUsuarios(); } catch (error) { console.error("Error al guardar:", error); + setSnackbarMessage("Error al guardar cambios"); + setSnackbarSeverity("error"); + setSnackbarOpen(true); } }; - //Abrir modal suspender/activar + // Acciones (Suspender/Activar) const handleAccionUsuario = (row) => { const accion = row.estado === "activo" ? "suspender" : "activar"; setAccionUsuario({ row, accion }); setConfirmOpen(true); }; - const ejecutarAccion = async () => { if (!accionUsuario) return; const { row, accion } = accionUsuario; - try { - const res = await fetch(`${API_URL}/api/admin/usuarios/${row.id}/${accion}`, { - method: "PATCH", - }); - + const res = await fetch( + `${API_URL}/api/admin/usuarios/${row.id}/${accion}`, + { method: "PATCH" } + ); const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Error en la acción"); - // Actualizar estado en la tabla + // Actualizar fila setRows((prev) => prev.map((u) => u.id === row.id ? { ...u, estado: data.usuario.estado } : u @@ -174,25 +199,22 @@ export default function UsuariosAdmin() { ); setSnackbarMessage( - accion === "suspender" - ? "Usuario suspendido" - : "Usuario activado" + accion === "suspender" ? "Usuario suspendido" : "Usuario activado" ); - setSnackbarSeverity(accion === "suspender" ? "error" : "success"); - setSnackbarOpen(true); - + setSnackbarSeverity(accion === "suspender" ? "warning" : "success"); // Warning queda mejor visualmente para suspender setSnackbarOpen(true); } catch (error) { console.error(error); setSnackbarMessage(`Error: ${error.message}`); + setSnackbarSeverity("error"); setSnackbarOpen(true); } finally { setConfirmOpen(false); setAccionUsuario(null); } }; - - //Abrir modal eliminar + + // Eliminar const handleBorrarUsuario = (row) => { setBorrarUsuario(row); setConfirmBorrarOpen(true); @@ -201,36 +223,36 @@ export default function UsuariosAdmin() { const ejecutarBorrado = async () => { if (!borrarUsuario) return; try { - const res = await fetch(`${API_URL}/api/admin/usuarios/${borrarUsuario.id}`, { - method: "DELETE", - }); + const res = await fetch( + `${API_URL}/api/admin/usuarios/${borrarUsuario.id}`, + { method: "DELETE" } + ); const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Error al eliminar usuario"); - // Actualizar tabla setRows((prev) => prev.filter((u) => u.id !== borrarUsuario.id)); + setRowCount((prev) => prev - 1); // Ajustar contador visualmente setSnackbarMessage(`Usuario borrado`); - setSnackbarSeverity("error"); // rojo para eliminar + setSnackbarSeverity("error"); setSnackbarOpen(true); } catch (error) { console.error(error); setSnackbarMessage(`Error: ${error.message}`); setSnackbarSeverity("error"); setSnackbarOpen(true); - } finally { + } finally { setConfirmBorrarOpen(false); setBorrarUsuario(null); } }; - + const columns = useMemo( () => [ { field: "id", headerName: "ID", width: 70 }, { field: "nombre", headerName: "Nombre", width: 130 }, { field: "email", headerName: "Email", width: 200 }, - { field: "rol", headerName: "Rol", width: 60 }, + { field: "rol", headerName: "Rol", width: 80 }, { field: "estado", headerName: "Estado", width: 100 }, { field: "fecha_registro", headerName: "Fecha", width: 120 }, { @@ -251,7 +273,7 @@ export default function UsuariosAdmin() { size="small" sx={{ mr: 1 }} component={Link} - to={`/perfil/${row.slug}`} + to={`/perfil/${row.slug || row.id}`} // Fallback si no hay slug target="_blank" > Ver @@ -272,7 +294,7 @@ export default function UsuariosAdmin() { variant="contained" color="inherit" size="small" - sx={{ mr: 1, width: 70 }} + sx={{ mr: 1, width: 90 }} disabled > Denegado @@ -282,7 +304,7 @@ export default function UsuariosAdmin() { variant="contained" color={row.estado === "activo" ? "secondary" : "success"} size="small" - sx={{ mr: 1, width: 80 }} + sx={{ mr: 1, width: 90 }} onClick={() => handleAccionUsuario(row)} > {row.estado === "activo" ? "Suspender" : "Activar"} @@ -303,13 +325,12 @@ export default function UsuariosAdmin() { variant="contained" color="inherit" size="small" - sx={{ mr: 1, width: 80 }} + sx={{ width: 80 }} disabled > Denegado )} - ); }, @@ -351,24 +372,28 @@ export default function UsuariosAdmin() { setPage(newPage)} - onPageSizeChange={(newPageSize) => setPageSize(newPageSize)} + + // NUEVA GESTIÓN DE PAGINACIÓN (v6+) + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + pageSizeOptions={[5, 10, 20]} autoHeight loading={loading} sx={{ border: 0 }} + + // Desactivar selección de filas si no se usa + disableRowSelectionOnClick /> - {/* MODAL */} + {/* MODAL EDITAR */} setOpen(false)} fullWidth maxWidth="sm"> Editar Usuario - - Rol - - - - + > + + Nombre + setNewTagName(e.target.value)} + /> - - {labelDisplayedRows({ - from: rows.length === 0 ? 0 : page * rowsPerPage + 1, - to: getLabelDisplayedRowsTo(), - count: rows.length === -1 ? -1 : rows.length, - })} - - - handleChangePage(page - 1)} - sx={{ bgcolor: 'background.surface' }} - > - - - = Math.ceil(rows.length / rowsPerPage) - 1 - : false + + + + + + + + + {/* --- TABLA --- */} + + setOpenModal(true)} + /> + theme.vars.palette.success.softBg, + '& thead th:nth-child(1)': { width: '40px' }, // Checkbox + '& thead th:nth-child(2)': { width: '10%' }, // ID + '& thead th:nth-child(3)': { width: '60%' }, // Nombre + '& tr > *:last-child': { textAlign: 'right' }, // Acciones + }} + > + + + {[...rows] + .sort(getComparator(order, orderBy)) + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((row, index) => { + const isItemSelected = selected.includes(row.id); + const labelId = `enhanced-table-checkbox-${index}`; + + return ( + handleChangePage(page + 1)} - sx={{ bgcolor: 'background.surface' }} > - - + + + + + + ); + })} + {emptyRows > 0 && ( + + + )} + + + + - - -
+ handleClick(event, row.id)} + slotProps={{ + input: { + 'aria-labelledby': labelId, + }, + }} + sx={{ verticalAlign: 'top' }} + /> + + {row.id} + + {row.nombre} + + + { + e.stopPropagation(); // Evitar que seleccione la fila al hacer click en borrar + handleDeleteEtiqueta(row.id); + }} + > + + + +
+
+ + + Filas por página: + + + + {labelDisplayedRows({ + from: rows.length === 0 ? 0 : page * rowsPerPage + 1, + to: getLabelDisplayedRowsTo(), + count: rows.length === -1 ? -1 : rows.length, + })} + + + handleChangePage(page - 1)} + sx={{ bgcolor: 'background.surface' }} + > + + + = Math.ceil(rows.length / rowsPerPage) - 1 + : false + } + onClick={() => handleChangePage(page + 1)} + sx={{ bgcolor: 'background.surface' }} + > + + + - -
-
+ + + + + ); -} +} \ No newline at end of file From 8ee25a274aaeed4108634e9a878743607db208c5 Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Sat, 22 Nov 2025 18:19:31 -0300 Subject: [PATCH 38/96] Funcionan los comentarios en el panel de admin. --- .../PanelAdmin/sections/ComentariosAdmin.jsx | 584 ++++++++++++++++-- 1 file changed, 549 insertions(+), 35 deletions(-) diff --git a/src/components/PanelAdmin/sections/ComentariosAdmin.jsx b/src/components/PanelAdmin/sections/ComentariosAdmin.jsx index ee1b8f5..6d853aa 100644 --- a/src/components/PanelAdmin/sections/ComentariosAdmin.jsx +++ b/src/components/PanelAdmin/sections/ComentariosAdmin.jsx @@ -1,54 +1,568 @@ import * as React from 'react'; -import Avatar from '@mui/joy/Avatar'; -import AvatarGroup from '@mui/joy/AvatarGroup'; +import PropTypes from 'prop-types'; import Box from '@mui/joy/Box'; -import Button from '@mui/joy/Button'; -import Card from '@mui/joy/Card'; -import CardContent from '@mui/joy/CardContent'; -import CardActions from '@mui/joy/CardActions'; -import IconButton from '@mui/joy/IconButton'; +import Table from '@mui/joy/Table'; import Typography from '@mui/joy/Typography'; -import FavoriteBorder from '@mui/icons-material/FavoriteBorder'; +import Sheet from '@mui/joy/Sheet'; +import FormControl from '@mui/joy/FormControl'; +import FormLabel from '@mui/joy/FormLabel'; +import IconButton from '@mui/joy/IconButton'; +import Button from '@mui/joy/Button'; +import Input from '@mui/joy/Input'; +import Link from '@mui/joy/Link'; +import Tooltip from '@mui/joy/Tooltip'; +import Select from '@mui/joy/Select'; +import Option from '@mui/joy/Option'; +import Checkbox from '@mui/joy/Checkbox'; // Importante + +// Iconos +import DeleteIcon from '@mui/icons-material/Delete'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import ClearIcon from '@mui/icons-material/Clear'; +import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft'; +import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import SearchIcon from '@mui/icons-material/Search'; + +import { visuallyHidden } from '@mui/utils'; import { CssVarsProvider } from '@mui/joy/styles'; import JoyCssBaseline from '@mui/joy/CssBaseline'; +const API_URL = import.meta.env.VITE_API_URL; + +// --- Funciones de Utilidad --- +function descendingComparator(a, b, orderBy) { + if (b[orderBy] < a[orderBy]) return -1; + if (b[orderBy] > a[orderBy]) return 1; + return 0; +} + +function getComparator(order, orderBy) { + return order === 'desc' + ? (a, b) => descendingComparator(a, b, orderBy) + : (a, b) => -descendingComparator(a, b, orderBy); +} + +// --- Configuración de Columnas --- +const headCells = [ + { id: 'id', numeric: true, disablePadding: true, label: 'ID' }, + { id: 'contenido', numeric: false, disablePadding: false, label: 'Comentario' }, + { id: 'id_usuario', numeric: true, disablePadding: false, label: 'User ID' }, + { id: 'id_publicacion', numeric: true, disablePadding: false, label: 'Pub ID' }, + { id: 'fecha_creacion', numeric: false, disablePadding: false, label: 'Fecha' }, + { id: 'acciones', numeric: false, disablePadding: false, label: 'Acciones' }, +]; + +// --- Cabecera de Tabla --- +function EnhancedTableHead(props) { + const { onSelectAllClick, order, orderBy, numSelected, rowCount, onRequestSort } = props; + const createSortHandler = (property) => (event) => { + onRequestSort(event, property); + }; + + return ( + + + {/* Columna Checkbox "Seleccionar Todo" */} + + 0 && numSelected < rowCount} + checked={rowCount > 0 && numSelected === rowCount} + onChange={onSelectAllClick} + slotProps={{ + input: { 'aria-label': 'select all comments' }, + }} + sx={{ verticalAlign: 'sub' }} + /> + + + {headCells.map((headCell) => { + const active = orderBy === headCell.id; + return ( + + {headCell.id !== 'acciones' ? ( + : null} + endDecorator={!headCell.numeric ? : null} + sx={{ + fontWeight: 'lg', + '& svg': { transition: '0.2s', transform: active && order === 'desc' ? 'rotate(0deg)' : 'rotate(180deg)' }, + '&:hover': { '& svg': { opacity: 1 } }, + }} + > + {headCell.label} + {active ? {order === 'desc' ? 'sorted descending' : 'sorted ascending'} : null} + + ) : ( + headCell.label + )} + + ); + })} + + + ); +} + +EnhancedTableHead.propTypes = { + numSelected: PropTypes.number.isRequired, + onRequestSort: PropTypes.func.isRequired, + onSelectAllClick: PropTypes.func.isRequired, + order: PropTypes.oneOf(['asc', 'desc']).isRequired, + orderBy: PropTypes.string.isRequired, + rowCount: PropTypes.number.isRequired, +}; + +// --- Barra de Herramientas (Filtros y Acciones Masivas) --- +function EnhancedTableToolbar({ numSelected, onDeleteSelected, filterPubId, setFilterPubId, onFilterSubmit, onClearFilter }) { + return ( + 0 ? 'background.level1' : 'background.surface', + borderRadius: 'sm', + mb: 2, + flexWrap: 'wrap', + gap: 2, + borderBottom: '1px solid', + borderColor: 'divider' + }} + > + {numSelected > 0 ? ( + + {numSelected} seleccionado(s) + + ) : ( + + Administrar Comentarios + + )} + + {numSelected > 0 ? ( + + + + + + ) : ( + +
{ + e.preventDefault(); + onFilterSubmit(); + }} + style={{ display: 'flex', gap: '8px', alignItems: 'flex-end' }} + > + + Filtrar por ID Publicación + setFilterPubId(e.target.value)} + startDecorator={} + endDecorator={ + filterPubId && ( + + + + ) + } + /> + + +
+
+ )} +
+ ); +} + +// --- COMPONENTE PRINCIPAL --- export default function ComentariosAdmin() { + const [order, setOrder] = React.useState('desc'); + const [orderBy, setOrderBy] = React.useState('id'); + const [selected, setSelected] = React.useState([]); // Estado para selección múltiple + const [page, setPage] = React.useState(0); + const [rowsPerPage, setRowsPerPage] = React.useState(10); + + // Datos + const [rows, setRows] = React.useState([]); + const [loading, setLoading] = React.useState(false); + + // Filtros + const [filterPubId, setFilterPubId] = React.useState(''); + const [activeFilter, setActiveFilter] = React.useState(null); + + // 1. Cargar Comentarios + // 1. Cargar Comentarios + const fetchComentarios = async (publicacionId = null) => { + setLoading(true); + try { + let url = `${API_URL}/comentarios`; + + if (publicacionId) { + url = `${API_URL}/comentarios/publicacion/${publicacionId}`; + } + + const response = await fetch(url); + + if (response.ok) { + const data = await response.json(); + let lista = Array.isArray(data) ? data : []; + + // --- FIX: Si filtramos, aseguramos que el ID exista en la fila --- + if (publicacionId) { + lista = lista.map(comentario => ({ + ...comentario, + // Si el backend no lo trae, usamos el ID que buscamos + id_publicacion: comentario.id_publicacion || publicacionId + })); + } + // --------------------------------------------------------------- + + setRows(lista); + setPage(0); + setSelected([]); + } else { + console.error('Error al cargar comentarios'); + setRows([]); + } + } catch (error) { + console.error('Error de red:', error); + } finally { + setLoading(false); + } + }; + + React.useEffect(() => { + fetchComentarios(); + }, []); + + // Filtros + const handleFilterSubmit = () => { + if (filterPubId.trim()) { + setActiveFilter(filterPubId); + fetchComentarios(filterPubId); + } else { + handleClearFilter(); + } + }; + + const handleClearFilter = () => { + setFilterPubId(''); + setActiveFilter(null); + fetchComentarios(null); + }; + + // 2. Eliminar Comentario (Individual) + const handleDeleteComentario = async (id) => { + if (!window.confirm(`¿Estás seguro de eliminar el comentario ID: ${id}?`)) return; + + try { + const response = await fetch(`${API_URL}/comentarios/${id}`, { + method: 'DELETE', + }); + + if (response.ok) { + setRows((prev) => prev.filter((row) => row.id !== id)); + setSelected((prev) => prev.filter((itemId) => itemId !== id)); + } else { + const errorData = await response.json(); + alert(`Error: ${errorData.error || 'No se pudo eliminar'}`); + } + } catch (error) { + console.error('Error al eliminar:', error); + } + }; + + // 3. Eliminar Múltiples (Bulk Delete) + const handleDeleteSelected = async () => { + if (!window.confirm(`¿Estás seguro de eliminar ${selected.length} comentarios seleccionados?`)) return; + + // Creamos un array de promesas para ejecutar los deletes + const deletePromises = selected.map((id) => + fetch(`${API_URL}/comentarios/${id}`, { method: 'DELETE' }) + ); + + try { + await Promise.all(deletePromises); + // Actualizamos la tabla quitando los que se borraron + setRows((prev) => prev.filter((row) => !selected.includes(row.id))); + setSelected([]); // Limpiar selección + } catch (error) { + console.error('Error en borrado masivo:', error); + alert('Ocurrió un error al intentar borrar algunos comentarios.'); + // Recargamos para asegurar consistencia + fetchComentarios(activeFilter); + } + }; + + // --- Manejadores de Selección --- + const handleSelectAllClick = (event) => { + if (event.target.checked) { + // Selecciona solo los visibles o todos (aquí seleccionamos TODOS los cargados) + const newSelected = rows.map((n) => n.id); + setSelected(newSelected); + return; + } + setSelected([]); + }; + + const handleClick = (event, id) => { + const selectedIndex = selected.indexOf(id); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, id); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat( + selected.slice(0, selectedIndex), + selected.slice(selectedIndex + 1), + ); + } + setSelected(newSelected); + }; + + // --- Manejadores de Tabla --- + const handleRequestSort = (event, property) => { + const isAsc = orderBy === property && order === 'asc'; + setOrder(isAsc ? 'desc' : 'asc'); + setOrderBy(property); + }; + + const handleChangePage = (newPage) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event, newValue) => { + setRowsPerPage(parseInt(newValue.toString(), 10)); + setPage(0); + }; + + // --- Cálculos de Paginación --- + function labelDisplayedRows({ from, to, count }) { + return `${from}–${to} de ${count !== -1 ? count : `más de ${to}`}`; + } + + const getLabelDisplayedRowsTo = () => { + if (rows.length === -1) return (page + 1) * rowsPerPage; + return rowsPerPage === -1 ? rows.length : Math.min(rows.length, (page + 1) * rowsPerPage); + }; + + const emptyRows = page > 0 ? Math.max(0, (1 + page) * rowsPerPage - rows.length) : 0; + return ( - - + + theme.vars.palette.neutral.softBg, + '& thead th:nth-child(1)': { width: '40px' }, // Checkbox col + '& tr > *:last-child': { textAlign: 'center' }, }} > - - - - NYC Coders - - We are a community of developers prepping for coding interviews, - participate, chat with others and get better at interviewing. - - - - - - - + + + {[...rows] + .sort(getComparator(order, orderBy)) + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((row, index) => { + const isItemSelected = selected.includes(row.id); + const labelId = `enhanced-table-checkbox-${index}`; + + return ( + + {/* Celda de Checkbox */} + + + + + + + + + + ); + })} + + {rows.length === 0 && !loading && ( + + + + )} + + {emptyRows > 0 && ( + + + )} + + + + + + +
+ handleClick(event, row.id)} + slotProps={{ + input: { 'aria-labelledby': labelId }, + }} + sx={{ verticalAlign: 'top' }} + /> + {row.id} + + {row.descripcion || "Sin contenido"} + + {row.id_usuario} + + {row.id_publicacion} + + + + {row.fecha_creacion ? new Date(row.fecha_creacion).toLocaleDateString() : '-'} + + + + { + e.stopPropagation(); // Evita seleccionar la fila al borrar + handleDeleteComentario(row.id); + }} + > + + + +
+ No se encontraron comentarios. +
+
+ + + Filas: + + + + {labelDisplayedRows({ + from: rows.length === 0 ? 0 : page * rowsPerPage + 1, + to: getLabelDisplayedRowsTo(), + count: rows.length === -1 ? -1 : rows.length, + })} + + + handleChangePage(page - 1)} + sx={{ bgcolor: 'background.surface' }} + > + + + = Math.ceil(rows.length / rowsPerPage) - 1 + : false + } + onClick={() => handleChangePage(page + 1)} + sx={{ bgcolor: 'background.surface' }} + > + + + + +
+
); -} +} \ No newline at end of file From 72dee483ba2005f59d69176b8d89f7fabd45a680 Mon Sep 17 00:00:00 2001 From: walorey Date: Sat, 22 Nov 2025 18:32:52 -0300 Subject: [PATCH 39/96] estadisticas basicas --- .../PanelAdmin/sections/HomeAdmin.jsx | 206 ++++++++---------- 1 file changed, 95 insertions(+), 111 deletions(-) diff --git a/src/components/PanelAdmin/sections/HomeAdmin.jsx b/src/components/PanelAdmin/sections/HomeAdmin.jsx index 1d36b3e..0e2a3a1 100644 --- a/src/components/PanelAdmin/sections/HomeAdmin.jsx +++ b/src/components/PanelAdmin/sections/HomeAdmin.jsx @@ -1,118 +1,102 @@ -import * as React from 'react'; -import { BarChart } from "@mui/x-charts/BarChart"; +import { useEffect, useState } from "react"; +import { Grid, Paper, Typography } from "@mui/material"; +import PersonIcon from '@mui/icons-material/Person'; +import ArticleIcon from '@mui/icons-material/Article'; +import ReportIcon from '@mui/icons-material/Report'; -const otherSetting = { - height: 300, - yAxis: [{ label: 'rainfall (mm)', width: 60 }], - grid: { horizontal: true }, -}; +export default function HomeAdmin() { + const [stats, setStats] = useState({ + usuarios: 0, + publicaciones: 0, + reportes: 0, + }); -const dataset = [ - { - london: 59, - paris: 57, - newYork: 86, - seoul: 21, - month: 'January', - }, - { - london: 50, - paris: 52, - newYork: 78, - seoul: 28, - month: 'February', - }, - { - london: 47, - paris: 53, - newYork: 106, - seoul: 41, - month: 'March', - }, - { - london: 54, - paris: 56, - newYork: 92, - seoul: 73, - month: 'April', - }, - { - london: 57, - paris: 69, - newYork: 92, - seoul: 99, - month: 'May', - }, - { - london: 60, - paris: 63, - newYork: 103, - seoul: 144, - month: 'June', - }, - { - london: 59, - paris: 60, - newYork: 105, - seoul: 319, - month: 'July', - }, - { - london: 65, - paris: 60, - newYork: 106, - seoul: 249, - month: 'August', - }, - { - london: 51, - paris: 51, - newYork: 95, - seoul: 131, - month: 'September', - }, - { - london: 60, - paris: 65, - newYork: 97, - seoul: 55, - month: 'October', - }, - { - london: 67, - paris: 64, - newYork: 76, - seoul: 48, - month: 'November', - }, - { - london: 61, - paris: 70, - newYork: 103, - seoul: 25, - month: 'December', - }, -]; + const API_URL = import.meta.env.VITE_API_URL; -const valueFormatter = (value) => `${value}mm`; + useEffect(() => { + fetchEstadisticas(); + }, []); + + const fetchEstadisticas = async () => { + try { + const res = await fetch(`${API_URL}/api/admin/estadisticas`); + const data = await res.json(); + + setStats({ + usuarios: data.usuarios || 0, + publicaciones: data.publicaciones || 0, + reportes: data.reportes || 0, + }); + } catch (error) { + console.error("Error al traer estadísticas", error); + } + }; -export default function HomeAdmin() { return ( - - context.location === 'tick' - ? `${month.slice(0, 3)} \n2023` - : `${month} 2023`, - height: 40, - }, - ]} - series={[{ dataKey: 'seoul', label: 'Seoul rainfall', valueFormatter }]} - {...otherSetting} - /> + + + + Usuarios registrados + +
+ + + {stats.usuarios} + +
+
+
+ + + + Publicaciones creadas + +
+ + + {stats.publicaciones} + +
+
+
+ + + + Reportes pendientes + +
+ + + {stats.reportes} + +
+
+
+
+ ); } From 9a003071f3057477ec38d1464432f2baad8d8791 Mon Sep 17 00:00:00 2001 From: walorey Date: Sat, 22 Nov 2025 19:03:48 -0300 Subject: [PATCH 40/96] un par de iconos --- src/components/Navbar.jsx | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 3eb9b93..16a1514 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -6,6 +6,9 @@ import IconButton from '@mui/material/IconButton'; import Typography from '@mui/material/Typography'; import Menu from '@mui/material/Menu'; import MenuIcon from '@mui/icons-material/Menu'; +import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; +import SettingsIcon from '@mui/icons-material/Settings'; +import LogoutIcon from '@mui/icons-material/Logout'; import Container from '@mui/material/Container'; import Avatar from '@mui/material/Avatar'; import Button from '@mui/material/Button'; @@ -308,17 +311,49 @@ const settings = ['Notificaciones', 'Mi perfil', 'Configuración', 'Cerrar sesi {isAdmin && ( { handleCloseUserMenu(); navigate('/admin/panel'); }}> + Panel Admin )} handleUserMenuClick(setting)}> + {setting === "Configuración" && ( + + )} + + {setting === "Notificaciones" && ( + + )} + + {setting === "Mi perfil" && ( + + )} + {setting === "Cerrar sesión" && ( + + )} {setting} + ) : ( handleUserMenuClick(setting)}> + {setting === "Configuración" && ( + + )} + + {setting === "Notificaciones" && ( + + )} + + {setting === "Mi perfil" && ( + + )} + {setting === "Cerrar sesión" && ( + + )} + {setting} + ) ))} From 256ecdea95d250ba08ab5fa8c3083854ca108ae1 Mon Sep 17 00:00:00 2001 From: walorey Date: Sat, 22 Nov 2025 19:26:24 -0300 Subject: [PATCH 41/96] mas iconos --- .../PanelAdmin/sections/HomeAdmin.jsx | 95 ++++++++++++++++++- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/src/components/PanelAdmin/sections/HomeAdmin.jsx b/src/components/PanelAdmin/sections/HomeAdmin.jsx index 0e2a3a1..f1d64f6 100644 --- a/src/components/PanelAdmin/sections/HomeAdmin.jsx +++ b/src/components/PanelAdmin/sections/HomeAdmin.jsx @@ -3,6 +3,10 @@ import { Grid, Paper, Typography } from "@mui/material"; import PersonIcon from '@mui/icons-material/Person'; import ArticleIcon from '@mui/icons-material/Article'; import ReportIcon from '@mui/icons-material/Report'; +import VolunteerActivismIcon from '@mui/icons-material/VolunteerActivism'; +import SosIcon from '@mui/icons-material/Sos'; +import MyLocationIcon from '@mui/icons-material/MyLocation'; +import SearchIcon from '@mui/icons-material/Search'; export default function HomeAdmin() { const [stats, setStats] = useState({ @@ -35,7 +39,7 @@ export default function HomeAdmin() { return ( - + Usuarios registrados
- + Publicaciones creadas
- + Reportes pendientes
+ + + + Mascotas perdidas + +
+ + + 000+ + +
+
+
+ + + + Mascotas encontradas + +
+ + + 000+ + +
+
+
+ + + + Mascotas en adopción + +
+ + + 000+ + +
+
+
+ + + + Mascotas en estado critico + +
+ + + 000+ + +
+
+
+ ); From b750e660208af1f869ff45be42de6c38d450654a Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Sat, 22 Nov 2025 20:16:54 -0300 Subject: [PATCH 42/96] Ubicaciones en el panel de admin, te permite abm localidades en los distintos departamentos --- .../PanelAdmin/sections/UbicacionesAdmin.jsx | 487 ++++++++++++++++-- 1 file changed, 438 insertions(+), 49 deletions(-) diff --git a/src/components/PanelAdmin/sections/UbicacionesAdmin.jsx b/src/components/PanelAdmin/sections/UbicacionesAdmin.jsx index 1066ce8..429e645 100644 --- a/src/components/PanelAdmin/sections/UbicacionesAdmin.jsx +++ b/src/components/PanelAdmin/sections/UbicacionesAdmin.jsx @@ -1,60 +1,449 @@ import * as React from 'react'; +import Box from '@mui/joy/Box'; import Table from '@mui/joy/Table'; +import Typography from '@mui/joy/Typography'; +import Sheet from '@mui/joy/Sheet'; +import FormControl from '@mui/joy/FormControl'; +import FormLabel from '@mui/joy/FormLabel'; +import IconButton from '@mui/joy/IconButton'; +import Button from '@mui/joy/Button'; +import Input from '@mui/joy/Input'; +import Tooltip from '@mui/joy/Tooltip'; +import Select from '@mui/joy/Select'; +import Option from '@mui/joy/Option'; +import Modal from '@mui/joy/Modal'; +import ModalDialog from '@mui/joy/ModalDialog'; + +// Iconos +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import AddIcon from '@mui/icons-material/Add'; +import LocationOnIcon from '@mui/icons-material/LocationOn'; + import { CssVarsProvider } from '@mui/joy/styles'; import JoyCssBaseline from '@mui/joy/CssBaseline'; +const API_URL = import.meta.env.VITE_API_URL; + export default function UbicacionesAdmin() { + // --- ESTADOS DE DATOS --- + const [provincias, setProvincias] = React.useState([]); + const [departamentos, setDepartamentos] = React.useState([]); + const [localidades, setLocalidades] = React.useState([]); + + // --- ESTADOS DE FILTRO (Toolbar) --- + const [selectedProvincia, setSelectedProvincia] = React.useState(null); + const [selectedDepartamento, setSelectedDepartamento] = React.useState(null); + + // --- ESTADOS DEL MODAL (Crear/Editar) --- + const [openModal, setOpenModal] = React.useState(false); + const [isEditing, setIsEditing] = React.useState(false); + const [currentLocalidadId, setCurrentLocalidadId] = React.useState(null); + + // Formulario + const [formData, setFormData] = React.useState({ + nombre: '', + latitud: '', + longitud: '', + id_provincia: '', // Necesario para la lógica visual del select + id_departamento: '' // Necesario para la FK en la base de datos + }); + + // Departamentos específicos del modal (para cuando editamos y la localidad puede ser de otra provincia) + const [modalDepartamentos, setModalDepartamentos] = React.useState([]); + + // ---------------------------------------------------------------- + // 1. CARGA INICIAL (Provincias) + // ---------------------------------------------------------------- + React.useEffect(() => { + fetchProvincias(); + }, []); + + const fetchProvincias = async () => { + try { + const res = await fetch(`${API_URL}/api/ubicacion/provincias`); + if (res.ok) setProvincias(await res.json()); + } catch (error) { + console.error("Error cargando provincias:", error); + } + }; + + // ---------------------------------------------------------------- + // 2. MANEJO DE FILTROS EN CASCADA (Toolbar Principal) + // ---------------------------------------------------------------- + + // Al cambiar Provincia -> Cargo departamentos + const handleFilterProvinciaChange = async (event, newValue) => { + setSelectedProvincia(newValue); + setSelectedDepartamento(null); // Reset departamento + setLocalidades([]); // Limpiar tabla + setDepartamentos([]); // Limpiar select de deptos + + if (newValue) { + try { + const res = await fetch(`${API_URL}/api/ubicacion/departamentos?provincia_id=${newValue}`); + if (res.ok) setDepartamentos(await res.json()); + } catch (error) { + console.error(error); + } + } + }; + + // Al cambiar Departamento -> Cargo Localidades + const handleFilterDepartamentoChange = async (event, newValue) => { + setSelectedDepartamento(newValue); + if (newValue) { + fetchLocalidades(newValue); + } else { + setLocalidades([]); + } + }; + + const fetchLocalidades = async (deptId) => { + try { + const res = await fetch(`${API_URL}/api/ubicacion/localidades?departamento_id=${deptId}`); + if (res.ok) setLocalidades(await res.json()); + } catch (error) { + console.error("Error cargando localidades:", error); + } + }; + + // ---------------------------------------------------------------- + // 3. LOGICA DEL MODAL (Crear / Editar) + // ---------------------------------------------------------------- + + // Abrir modal para CREAR + const handleOpenCreate = () => { + setIsEditing(false); + + // Pre-llenamos el formulario con lo que el usuario ya seleccionó en el filtro + // para ahorrarle clics. + const provId = selectedProvincia || ''; + const deptId = selectedDepartamento || ''; + + setFormData({ + nombre: '', + latitud: '', + longitud: '', + id_provincia: provId, + id_departamento: deptId + }); + + // Si hay provincia seleccionada, usamos los departamentos que ya tenemos cargados + if (provId) { + setModalDepartamentos(departamentos); + } else { + setModalDepartamentos([]); + } + setOpenModal(true); + }; + + // Abrir modal para EDITAR + const handleOpenEdit = async (localidad) => { + setIsEditing(true); + setCurrentLocalidadId(localidad.id); + + try { + // 1. Obtenemos el detalle completo para saber el ID de Provincia y Departamento + const res = await fetch(`${API_URL}/api/ubicacion/localidades/${localidad.id}`); + const data = await res.json(); + + if (res.ok) { + // 2. Cargamos los departamentos de la provincia a la que pertenece esta localidad + // (Puede ser distinta a la del filtro actual si los datos estuvieran mezclados) + const resDept = await fetch(`${API_URL}/api/ubicacion/departamentos?provincia_id=${data.id_provincia}`); + const depts = await resDept.json(); + setModalDepartamentos(depts); + + // 3. Llenamos el form + setFormData({ + nombre: data.nombre, + latitud: data.latitud || '', + longitud: data.longitud || '', + id_provincia: data.id_provincia, + id_departamento: data.id_departamento + }); + setOpenModal(true); + } + } catch (error) { + console.error("Error obteniendo detalles:", error); + } + }; + + // Cascada interna del Modal (Si cambia provincia en el modal, recargar deptos del modal) + const handleModalProvinciaChange = async (e, newValue) => { + setFormData(prev => ({ ...prev, id_provincia: newValue, id_departamento: '' })); + if (newValue) { + const res = await fetch(`${API_URL}/api/ubicacion/departamentos?provincia_id=${newValue}`); + if (res.ok) setModalDepartamentos(await res.json()); + } else { + setModalDepartamentos([]); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + const payload = { + nombre: formData.nombre, + latitud: formData.latitud, + longitud: formData.longitud, + id_departamento: formData.id_departamento // FK Obligatoria según tu modelo + }; + + try { + let url = `${API_URL}/api/ubicacion/localidades`; + let method = 'POST'; + + if (isEditing) { + url = `${url}/${currentLocalidadId}`; + method = 'PATCH'; + } + + const res = await fetch(url, { + method: method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + setOpenModal(false); + // Si estamos visualizando el mismo departamento donde guardamos/editamos, refrescar tabla + if (selectedDepartamento && Number(formData.id_departamento) === Number(selectedDepartamento)) { + fetchLocalidades(selectedDepartamento); + } else if (selectedDepartamento) { + // Si movimos la localidad a otro departamento, avisar y refrescar (desaparecerá de la vista actual) + alert("Guardado correctamente. La localidad se movió a otro partido/departamento."); + fetchLocalidades(selectedDepartamento); + } + } else { + const err = await res.json(); + alert(err.error || "Error al guardar"); + } + } catch (error) { + console.error(error); + } + }; + + // ---------------------------------------------------------------- + // 4. BORRAR + // ---------------------------------------------------------------- + const handleDelete = async (id) => { + if (!window.confirm("¿Estás seguro de eliminar esta localidad?")) return; + try { + const res = await fetch(`${API_URL}/api/ubicacion/localidades/${id}`, { + method: 'DELETE' + }); + if (res.ok) { + setLocalidades(prev => prev.filter(l => l.id !== id)); + } else { + alert("Error al eliminar"); + } + } catch (error) { + console.error(error); + } + }; + return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
LocalidadPartidoProvinciaLatitudLongitud
GarinEscobarBuenos Aires244
General PachecoTigreBuenos Aires374.3
CardalesCampanaBuenos Aires246
PalermoComuna 14Ciudad autónoma de Buenos Aires674.3
Gingerbread35616493.9
+ + {/* --- BARRA DE HERRAMIENTAS / FILTROS --- */} + + + 1. Provincia + + + + + 2. Partido / Departamento + + + + + + + + + {/* --- TABLA DE DATOS --- */} + + + + + + + + + + + + + {localidades.length > 0 ? ( + localidades.map((row) => ( + + + + + + + + )) + ) : ( + + + + )} + +
IDLocalidadLatitudLongitudAcciones
{row.id} + }> + {row.nombre} + + {row.latitud}{row.longitud} + + + handleOpenEdit(row)}> + + + + + handleDelete(row.id)}> + + + + +
+ {!selectedDepartamento + ? "Por favor selecciona una Provincia y un Partido para gestionar las localidades." + : "No hay localidades cargadas en este partido."} +
+
+ + {/* --- MODAL FORMULARIO --- */} + setOpenModal(false)}> + + + {isEditing ? 'Editar Localidad' : 'Nueva Localidad'} + +
+ + + {/* Select Provincia (Solo lectura visual o cambio de contexto) */} + + Provincia + + + + {/* Select Departamento (Define la FK id_departamento) */} + + Partido / Departamento + + + + + Nombre Localidad + setFormData({ ...formData, nombre: e.target.value })} + /> + + + {/* Inputs Lat/Long adaptados para Numeric(15,10) */} + + + Latitud + setFormData({ ...formData, latitud: e.target.value })} + /> + + + Longitud + setFormData({ ...formData, longitud: e.target.value })} + /> + + + + + + + + +
+
+
+
); -} +} \ No newline at end of file From baea3d48e1286731692ee2bd4ba9e1dbc54a7d8b Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Sat, 22 Nov 2025 21:24:22 -0300 Subject: [PATCH 43/96] reportes en panel de admin --- .../PanelAdmin/sections/ReportesAdmin.jsx | 603 +++++++++++++----- 1 file changed, 461 insertions(+), 142 deletions(-) diff --git a/src/components/PanelAdmin/sections/ReportesAdmin.jsx b/src/components/PanelAdmin/sections/ReportesAdmin.jsx index b8ff6e4..9c1ad7a 100644 --- a/src/components/PanelAdmin/sections/ReportesAdmin.jsx +++ b/src/components/PanelAdmin/sections/ReportesAdmin.jsx @@ -1,165 +1,484 @@ -import * as React from "react"; -import { useState, useEffect } from "react"; -import Box from '@mui/material/Box'; -import Toolbar from "@mui/material/Toolbar"; -import Grid from "@mui/material/Grid"; -import Button from "@mui/material/Button"; -import TextField from "@mui/material/TextField"; -import Tooltip from "@mui/material/Tooltip"; -import IconButton from "@mui/material/IconButton"; -import SearchIcon from "@mui/icons-material/Search"; -import RefreshIcon from "@mui/icons-material/Refresh"; -import { DataGrid } from "@mui/x-data-grid"; - -// URL del backend Flask +import * as React from 'react'; +import PropTypes from 'prop-types'; +import Box from '@mui/joy/Box'; +import Table from '@mui/joy/Table'; +import Typography from '@mui/joy/Typography'; +import Sheet from '@mui/joy/Sheet'; +import FormControl from '@mui/joy/FormControl'; +import FormLabel from '@mui/joy/FormLabel'; +import IconButton from '@mui/joy/IconButton'; +import Button from '@mui/joy/Button'; +import Input from '@mui/joy/Input'; +import Link from '@mui/joy/Link'; +import Tooltip from '@mui/joy/Tooltip'; +import Select from '@mui/joy/Select'; +import Option from '@mui/joy/Option'; +import Checkbox from '@mui/joy/Checkbox'; +import Chip from '@mui/joy/Chip'; + +// Iconos +import DeleteIcon from '@mui/icons-material/Delete'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import ClearIcon from '@mui/icons-material/Clear'; +import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft'; +import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import SearchIcon from '@mui/icons-material/Search'; + +import { visuallyHidden } from '@mui/utils'; +import { CssVarsProvider } from '@mui/joy/styles'; +import JoyCssBaseline from '@mui/joy/CssBaseline'; + const API_URL = import.meta.env.VITE_API_URL; -// Columnas de la tabla -const columns = (handleEliminar) => [ - { field: "id", headerName: "ID", width: 70 }, - { field: "id_publicacion", headerName: "ID Publicación", width: 130 }, - { field: "usuario", headerName: "Usuario", width: 150 }, - { field: "tipo", headerName: "Tipo", width: 160 }, - { field: "descripcion", headerName: "Descripción", width: 250, flex: 1 }, - { field: "fecha", headerName: "Fecha", width: 160 }, - { - field: "acciones", - headerName: "Acciones", - width: 300, - sortable: false, - filterable: false, - renderCell: (params) => { - const id = params.row.id; - return ( - - - - - - ); - }, - }, +// --- Funciones de Utilidad --- +function descendingComparator(a, b, orderBy) { + if (b[orderBy] < a[orderBy]) return -1; + if (b[orderBy] > a[orderBy]) return 1; + return 0; +} + +function getComparator(order, orderBy) { + return order === 'desc' + ? (a, b) => descendingComparator(a, b, orderBy) + : (a, b) => -descendingComparator(a, b, orderBy); +} + +// --- Configuración de Columnas --- +const headCells = [ + { id: 'id', numeric: true, disablePadding: true, label: 'ID' }, + { id: 'tipo', numeric: false, disablePadding: false, label: 'Tipo' }, + { id: 'descripcion', numeric: false, disablePadding: false, label: 'Descripción' }, + { id: 'id_usuario', numeric: true, disablePadding: false, label: 'Usuario' }, + { id: 'id_publicacion', numeric: true, disablePadding: false, label: 'Pub ID' }, + { id: 'fecha_creacion', numeric: false, disablePadding: false, label: 'Fecha' }, + { id: 'acciones', numeric: false, disablePadding: false, label: 'Acciones' }, ]; -const paginationModel = { page: 0, pageSize: 5 }; +// --- Cabecera de Tabla --- +function EnhancedTableHead(props) { + const { onSelectAllClick, order, orderBy, numSelected, rowCount, onRequestSort } = props; + const createSortHandler = (property) => (event) => { + onRequestSort(event, property); + }; + + return ( + + + + 0 && numSelected < rowCount} + checked={rowCount > 0 && numSelected === rowCount} + onChange={onSelectAllClick} + slotProps={{ input: { 'aria-label': 'select all reports' } }} + sx={{ verticalAlign: 'sub' }} + /> + + + {headCells.map((headCell) => { + const active = orderBy === headCell.id; + return ( + + {headCell.id !== 'acciones' ? ( + : null} + endDecorator={!headCell.numeric ? : null} + sx={{ + fontWeight: 'lg', + '& svg': { transition: '0.2s', transform: active && order === 'desc' ? 'rotate(0deg)' : 'rotate(180deg)' }, + '&:hover': { '& svg': { opacity: 1 } }, + }} + > + {headCell.label} + {active ? {order === 'desc' ? 'sorted descending' : 'sorted ascending'} : null} + + ) : ( + headCell.label + )} + + ); + })} + + + ); +} +// --- Barra de Herramientas con Filtro --- +function EnhancedTableToolbar({ numSelected, onDeleteSelected, filterPubId, setFilterPubId, onFilterSubmit, onClearFilter }) { + return ( + 0 ? 'background.level1' : 'background.surface', + borderRadius: 'sm', + mb: 2, + flexWrap: 'wrap', + gap: 2, + borderBottom: '1px solid', + borderColor: 'divider' + }} + > + {numSelected > 0 ? ( + + {numSelected} seleccionado(s) + + ) : ( + + Gestión de Reportes + + )} + + {numSelected > 0 ? ( + + + + + + ) : ( + /* SECCIÓN DE FILTROS (IGUAL A COMENTARIOS) */ + +
{ + e.preventDefault(); + onFilterSubmit(); + }} + style={{ display: 'flex', gap: '8px', alignItems: 'flex-end' }} + > + + Filtrar por ID Publicación + setFilterPubId(e.target.value)} + startDecorator={} + endDecorator={ + filterPubId && ( + + + + ) + } + /> + + +
+
+ )} +
+ ); +} + +// --- COMPONENTE PRINCIPAL --- export default function ReportesAdmin() { - const [rows, setRows] = useState([]); - const [loading, setLoading] = useState(true); + const [order, setOrder] = React.useState('desc'); + const [orderBy, setOrderBy] = React.useState('id'); + const [selected, setSelected] = React.useState([]); + const [page, setPage] = React.useState(0); + const [rowsPerPage, setRowsPerPage] = React.useState(10); + + const [rows, setRows] = React.useState([]); + const [loading, setLoading] = React.useState(false); + + // Estado para filtros + const [filterPubId, setFilterPubId] = React.useState(''); + const [activeFilter, setActiveFilter] = React.useState(null); - // Obtener reportes - const fetchReportes = async () => { + // 1. Obtener Reportes (Con lógica de filtro) + const fetchReportes = async (publicacionId = null) => { + setLoading(true); try { - setLoading(true); - const res = await fetch(`${API_URL}/reportes`); - const data = await res.json(); - - const formateados = data.map((r) => ({ - id: r.id, - id_publicacion: r.id_publicacion, - usuario: r.usuario || `Usuario ${r.id_usuario}`, - tipo: r.tipo, - descripcion: r.descripcion, - fecha: r.fecha || "N/A", - })); - - setRows(formateados); + let url = `${API_URL}/reportes`; + + // Si hay filtro, cambiamos URL + if (publicacionId) { + url = `${API_URL}/reportes/publicacion/${publicacionId}`; + } + + const res = await fetch(url); + if (res.ok) { + const data = await res.json(); + let lista = Array.isArray(data) ? data : []; + + // --- INYECCIÓN DE ID SI FALTA (Para que el link funcione) --- + if (publicacionId) { + lista = lista.map(reporte => ({ + ...reporte, + id_publicacion: reporte.id_publicacion || publicacionId + })); + } + // ----------------------------------------------------------- + + setRows(lista); + setPage(0); + setSelected([]); // Limpiar selección al cambiar vista + } else { + console.error("Error obteniendo reportes"); + setRows([]); + } } catch (error) { - console.error("Error al obtener reportes:", error); + console.error("Error de red:", error); } finally { setLoading(false); } }; - // Eliminar reporte - const handleEliminar = async (id) => { + React.useEffect(() => { + fetchReportes(); + }, []); + + // Manejadores de Filtro + const handleFilterSubmit = () => { + if (filterPubId.trim()) { + setActiveFilter(filterPubId); + fetchReportes(filterPubId); + } else { + handleClearFilter(); + } + }; + + const handleClearFilter = () => { + setFilterPubId(''); + setActiveFilter(null); + fetchReportes(null); // Traer todos + }; + + // 2. Eliminar Individual + const handleDelete = async (id) => { + if (!window.confirm("¿Estás seguro de eliminar/cerrar este reporte?")) return; try { - await fetch(`${API_URL}/reportes/${id}`, { - method: "DELETE", - }); - setRows(rows.filter((r) => r.id !== id)); + const res = await fetch(`${API_URL}/reportes/${id}`, { method: 'DELETE' }); + if (res.ok) { + setRows(prev => prev.filter(r => r.id !== id)); + setSelected(prev => prev.filter(itemId => itemId !== id)); + } else { + alert("Error al eliminar reporte"); + } } catch (error) { - console.error("Error al eliminar reporte:", error); + console.error(error); } }; - useEffect(() => { - fetchReportes(); - }, []); + // 3. Eliminar Masivo + const handleDeleteSelected = async () => { + if (!window.confirm(`¿Eliminar ${selected.length} reportes?`)) return; + + const promises = selected.map(id => + fetch(`${API_URL}/reportes/${id}`, { method: 'DELETE' }) + ); + + try { + await Promise.all(promises); + setRows(prev => prev.filter(r => !selected.includes(r.id))); + setSelected([]); + } catch (error) { + console.error("Error en borrado masivo", error); + } + }; + + // --- Helpers UI --- + const getChipColor = (tipo) => { + if (!tipo) return 'neutral'; + const lower = tipo.toLowerCase(); + if (lower.includes('abuso') || lower.includes('fraude') || lower.includes('peligroso')) return 'danger'; + if (lower.includes('spam') || lower.includes('engañoso')) return 'warning'; + return 'primary'; + }; + + // --- Manejadores de Tabla --- + const handleRequestSort = (event, property) => { + const isAsc = orderBy === property && order === 'asc'; + setOrder(isAsc ? 'desc' : 'asc'); + setOrderBy(property); + }; + + const handleSelectAllClick = (event) => { + if (event.target.checked) { + const newSelected = rows.map((n) => n.id); + setSelected(newSelected); + return; + } + setSelected([]); + }; + + const handleClick = (event, id) => { + const selectedIndex = selected.indexOf(id); + let newSelected = []; + if (selectedIndex === -1) newSelected = newSelected.concat(selected, id); + else if (selectedIndex === 0) newSelected = newSelected.concat(selected.slice(1)); + else if (selectedIndex === selected.length - 1) newSelected = newSelected.concat(selected.slice(0, -1)); + else if (selectedIndex > 0) newSelected = newSelected.concat(selected.slice(0, selectedIndex), selected.slice(selectedIndex + 1)); + setSelected(newSelected); + }; + + const handleChangePage = (newPage) => setPage(newPage); + const handleChangeRowsPerPage = (event, newValue) => { + setRowsPerPage(parseInt(newValue.toString(), 10)); + setPage(0); + }; + + // Paginación + const emptyRows = page > 0 ? Math.max(0, (1 + page) * rowsPerPage - rows.length) : 0; + function labelDisplayedRows({ from, to, count }) { return `${from}–${to} de ${count !== -1 ? count : `más de ${to}`}`; } + const getLabelDisplayedRowsTo = () => rows.length === -1 ? (page + 1) * rowsPerPage : (rowsPerPage === -1 ? rows.length : Math.min(rows.length, (page + 1) * rowsPerPage)); return ( - - {/* Filtros y acciones */} - - - - - - - - - - - - - - - - - - - - {/* Tabla responsive */} - - + + + - - + + *:last-child': { textAlign: 'center' } }}> + + + {[...rows] + .sort(getComparator(order, orderBy)) + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((row, index) => { + const isItemSelected = selected.includes(row.id); + const labelId = `enhanced-table-checkbox-${index}`; + + return ( + + + + + + + + + + + ); + })} + + {rows.length === 0 && !loading && ( + + )} + {emptyRows > 0 && } + + + + + + +
+ handleClick(event, row.id)} + slotProps={{ input: { 'aria-labelledby': labelId } }} + /> + {row.id} + + {row.tipo} + + + + {row.descripcion} + + {row.id_usuario || 'Anon'} + + {row.id_publicacion} + + + + {/* Usamos fecha_creacion y formateamos incluyendo hora */} + {row.fecha_creacion + ? new Date(row.fecha_creacion).toLocaleString('es-AR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + : '-'} + + + + { + e.stopPropagation(); + handleDelete(row.id); + }}> + + + +
No hay reportes.
+ + + Filas: + + + + {labelDisplayedRows({ from: rows.length === 0 ? 0 : page * rowsPerPage + 1, to: getLabelDisplayedRowsTo(), count: rows.length === -1 ? -1 : rows.length })} + + + handleChangePage(page - 1)}> + + + = Math.ceil(rows.length / rowsPerPage) - 1 : false} onClick={() => handleChangePage(page + 1)}> + + + + +
+ + ); -} +} \ No newline at end of file From 68a26c8aae558d96aef42ad34473325cd448afc1 Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Sat, 22 Nov 2025 23:32:12 -0300 Subject: [PATCH 44/96] Boton para archivar publicaciones --- .../sections/PublicacionesAdmin.jsx | 202 ++++++++++++------ 1 file changed, 141 insertions(+), 61 deletions(-) diff --git a/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx b/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx index 93e8f9f..1a914b1 100644 --- a/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx +++ b/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx @@ -15,9 +15,9 @@ import Pagination from '@mui/material/Pagination'; import Stack from '@mui/material/Stack'; import Snackbar from '@mui/material/Snackbar'; import Alert from '@mui/material/Alert'; -import { red } from '@mui/material/colors'; +import { red, grey } from '@mui/material/colors'; // Agregué grey para estados import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material'; +import { Dialog, DialogTitle, DialogContent, DialogActions, Chip } from '@mui/material'; // Agregué Chip const ExpandMore = styled((props) => { const { expand, ...other } = props; @@ -48,14 +48,19 @@ export default function PublicacionesAdmin() { const [publicacionSeleccionada, setPublicacionSeleccionada] = React.useState(null); const [page, setPage] = React.useState(1); const [total, setTotal] = React.useState(0); + const [snackbarOpen, setSnackbarOpen] = React.useState(false); const [snackbarMessage, setSnackbarMessage] = React.useState(''); - const limit = 9; // 3 cards por fila + // Nuevo estado para controlar el color de la alerta (success/error) + const [snackbarSeverity, setSnackbarSeverity] = React.useState('success'); + + const limit = 9; const fetchPublicaciones = async (pagina = 1) => { try { const res = await fetch(`${API_URL}/api/admin/publicaciones?page=${pagina}&limit=${limit}`); const data = await res.json(); + console.log("Datos recibidos del backend:", data.publicaciones); const pubsConImagen = data.publicaciones.map(pub => ({ ...pub, primeraImagen: pub.imagenes?.[0] || null, @@ -76,6 +81,42 @@ export default function PublicacionesAdmin() { setExpanded(prev => ({ ...prev, [id]: !prev[id] })); }; + // --- Lógica para Archivar / Desarchivar --- + const handleArchivar = async (pub) => { + const isArchived = pub.estado === 1; // Asumiendo que el estado se llama 'archivada' o similar + const endpoint = isArchived ? 'desarchivar' : 'archivar'; + + try { + const res = await fetch(`${API_URL}/publicaciones/${pub.id}/${endpoint}`, { + method: 'PATCH', + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.error || `Error al ${endpoint}`); + } + + // Actualizar estado localmente para reflejar el cambio inmediato + setPublicaciones(prev => prev.map(p => { + if (p.id === pub.id) { + // Cambiamos el estado localmente + return { ...p, estado: isArchived ? 0 : 1 }; + } + return p; + })); + + setSnackbarMessage(isArchived ? "Publicación desarchivada" : "Publicación archivada"); + setSnackbarSeverity("success"); + setSnackbarOpen(true); + + } catch (error) { + console.error(error); + setSnackbarMessage(`Error: ${error.message}`); + setSnackbarSeverity("error"); + setSnackbarOpen(true); + } + }; + const handleBorrarPublicacionModal = (pub) => { setPublicacionSeleccionada(pub); setConfirmBorrarPubOpen(true); @@ -92,20 +133,19 @@ export default function PublicacionesAdmin() { throw new Error(data.error || "Error al eliminar publicación"); } - // Actualizar lista y cerrar modal setPublicaciones(prev => prev.filter(p => p.id !== publicacionSeleccionada.id)); setTotal(prev => prev - 1); setConfirmBorrarPubOpen(false); setPublicacionSeleccionada(null); - // Mostrar snackbar de éxito setSnackbarMessage("Publicación borrada"); + setSnackbarSeverity("success"); // Éxito setSnackbarOpen(true); } catch (error) { console.error(error); - // Mostrar snackbar de error setSnackbarMessage(`Error al eliminar: ${error.message}`); + setSnackbarSeverity("error"); // Error setSnackbarOpen(true); } }; @@ -121,60 +161,96 @@ export default function PublicacionesAdmin() { return ( <> - {publicaciones.map(pub => ( - - - {pub.usuario?.nombre?.[0] || "U"} - } - title={pub.titulo} - subheader={new Date(pub.fecha_creacion).toLocaleDateString("es-AR")} - /> - {pub.primeraImagen && ( - - )} - - - Propietario: {pub.usuario?.nombre || "Sin nombre"} - Email: {pub.usuario?.email || "Sin email"} - - - - - - handleExpandClick(pub.id)} - aria-expanded={expanded[pub.id]} - aria-label="show more" + {publicaciones.map(pub => { + // Verificamos si está archivada para estilos condicionales + const isArchived = pub.estado === 1; // 1 indica archivada + + return ( + + - - - - - - ))} + + {pub.usuario?.nombre?.[0] || "U"} + + } + action={ + // Indicador visual si está archivada + isArchived && + } + title={pub.titulo} + subheader={new Date(pub.fecha_creacion).toLocaleDateString("es-AR")} + /> + {pub.primeraImagen && ( + + )} + + + Propietario: {pub.usuario?.nombre || "Sin nombre"} + Email: {pub.usuario?.email || "Sin email"} + Estado: {pub.estado} + + + + + + + {/* BOTÓN ARCHIVAR / DESARCHIVAR */} + + + + + handleExpandClick(pub.id)} + aria-expanded={expanded[pub.id]} + aria-label="show more" + > + + + + + + ); + })} {/* Paginación */} @@ -209,10 +285,14 @@ export default function PublicacionesAdmin() { onClose={handleCloseSnackbar} anchorOrigin={{ vertical: 'top', horizontal: 'center' }} > - + {snackbarMessage} ); -} +} \ No newline at end of file From 360de3ddd04d7611c6de38d318a4fc7c63470010 Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Sun, 23 Nov 2025 00:06:59 -0300 Subject: [PATCH 45/96] Mejoras en publicaciones del panel admin --- .../sections/PublicacionesAdmin.jsx | 271 +++++++++++++----- 1 file changed, 196 insertions(+), 75 deletions(-) diff --git a/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx b/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx index 1a914b1..0008d72 100644 --- a/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx +++ b/src/components/PanelAdmin/sections/PublicacionesAdmin.jsx @@ -15,9 +15,14 @@ import Pagination from '@mui/material/Pagination'; import Stack from '@mui/material/Stack'; import Snackbar from '@mui/material/Snackbar'; import Alert from '@mui/material/Alert'; -import { red, grey } from '@mui/material/colors'; // Agregué grey para estados +import Box from '@mui/material/Box'; +import TextField from '@mui/material/TextField'; +import Checkbox from '@mui/material/Checkbox'; +import { red, grey } from '@mui/material/colors'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { Dialog, DialogTitle, DialogContent, DialogActions, Chip } from '@mui/material'; // Agregué Chip +import DeleteIcon from '@mui/icons-material/Delete'; +import SearchIcon from '@mui/icons-material/Search'; +import { Dialog, DialogTitle, DialogContent, DialogActions, Chip } from '@mui/material'; const ExpandMore = styled((props) => { const { expand, ...other } = props; @@ -32,7 +37,6 @@ const ExpandMore = styled((props) => { const API_URL = import.meta.env.VITE_API_URL; -// Componente de paginación personalizado function PaginationRounded({ count, page, onChange }) { return ( @@ -49,68 +53,118 @@ export default function PublicacionesAdmin() { const [page, setPage] = React.useState(1); const [total, setTotal] = React.useState(0); + // --- ESTADOS DE SELECCIÓN Y FILTRO --- + const [selectedIds, setSelectedIds] = React.useState([]); + const [filterUserId, setFilterUserId] = React.useState(''); + const [activeFilter, setActiveFilter] = React.useState(''); + // ------------------------------------- + const [snackbarOpen, setSnackbarOpen] = React.useState(false); const [snackbarMessage, setSnackbarMessage] = React.useState(''); - // Nuevo estado para controlar el color de la alerta (success/error) const [snackbarSeverity, setSnackbarSeverity] = React.useState('success'); const limit = 9; - const fetchPublicaciones = async (pagina = 1) => { + const fetchPublicaciones = async (pagina = 1, userId = '') => { try { - const res = await fetch(`${API_URL}/api/admin/publicaciones?page=${pagina}&limit=${limit}`); + let url = `${API_URL}/api/admin/publicaciones?page=${pagina}&limit=${limit}`; + + if (userId) { + url += `&id_usuario=${userId}`; + } + + const res = await fetch(url); const data = await res.json(); - console.log("Datos recibidos del backend:", data.publicaciones); - const pubsConImagen = data.publicaciones.map(pub => ({ + + const lista = Array.isArray(data.publicaciones) ? data.publicaciones : []; + + const pubsConImagen = lista.map(pub => ({ ...pub, primeraImagen: pub.imagenes?.[0] || null, })); setPublicaciones(pubsConImagen); - setTotal(data.total); - setPage(data.page); + setTotal(data.total || 0); + setPage(Number(data.page) || 1); } catch (error) { console.error(error); + setPublicaciones([]); } }; React.useEffect(() => { - fetchPublicaciones(page); - }, [page]); + fetchPublicaciones(page, activeFilter); + }, [page]); const handleExpandClick = (id) => { setExpanded(prev => ({ ...prev, [id]: !prev[id] })); }; - // --- Lógica para Archivar / Desarchivar --- + // --- MANEJO DE FILTRO --- + const handleSearch = () => { + setPage(1); + setActiveFilter(filterUserId); + fetchPublicaciones(1, filterUserId); + }; + + const handleClearSearch = () => { + setFilterUserId(''); + setActiveFilter(''); + setPage(1); + fetchPublicaciones(1, ''); + }; + + // --- MANEJO DE SELECCIÓN --- + const handleToggleSelect = (id) => { + setSelectedIds(prev => { + if (prev.includes(id)) { + return prev.filter(item => item !== id); + } else { + return [...prev, id]; + } + }); + }; + + // --- BORRADO MASIVO --- + const handleBulkDelete = async () => { + if (!window.confirm(`¿Estás seguro de eliminar ${selectedIds.length} publicaciones seleccionadas?`)) return; + + const deletePromises = selectedIds.map(id => + fetch(`${API_URL}/publicaciones/${id}`, { method: 'DELETE' }) + ); + + try { + await Promise.all(deletePromises); + fetchPublicaciones(page, activeFilter); + setSelectedIds([]); + setSnackbarMessage(`${selectedIds.length} publicaciones eliminadas.`); + setSnackbarSeverity("success"); + setSnackbarOpen(true); + } catch (error) { + console.error("Error en borrado masivo:", error); + setSnackbarMessage("Error al eliminar algunas publicaciones"); + setSnackbarSeverity("error"); + setSnackbarOpen(true); + } + }; + + // --- Lógica Individual --- const handleArchivar = async (pub) => { - const isArchived = pub.estado === 1; // Asumiendo que el estado se llama 'archivada' o similar + const isArchived = pub.estado === 1; const endpoint = isArchived ? 'desarchivar' : 'archivar'; try { - const res = await fetch(`${API_URL}/publicaciones/${pub.id}/${endpoint}`, { - method: 'PATCH', - }); + const res = await fetch(`${API_URL}/publicaciones/${pub.id}/${endpoint}`, { method: 'PATCH' }); + if (!res.ok) throw new Error(`Error al ${endpoint}`); - if (!res.ok) { - const errorData = await res.json(); - throw new Error(errorData.error || `Error al ${endpoint}`); - } - - // Actualizar estado localmente para reflejar el cambio inmediato setPublicaciones(prev => prev.map(p => { - if (p.id === pub.id) { - // Cambiamos el estado localmente - return { ...p, estado: isArchived ? 0 : 1 }; - } + if (p.id === pub.id) return { ...p, estado: isArchived ? 0 : 1 }; return p; })); setSnackbarMessage(isArchived ? "Publicación desarchivada" : "Publicación archivada"); setSnackbarSeverity("success"); setSnackbarOpen(true); - } catch (error) { - console.error(error); setSnackbarMessage(`Error: ${error.message}`); setSnackbarSeverity("error"); setSnackbarOpen(true); @@ -125,13 +179,8 @@ export default function PublicacionesAdmin() { const ejecutarBorradoPublicacion = async () => { if (!publicacionSeleccionada) return; try { - const res = await fetch(`${API_URL}/publicaciones/${publicacionSeleccionada.id}`, { - method: "DELETE", - }); - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "Error al eliminar publicación"); - } + const res = await fetch(`${API_URL}/publicaciones/${publicacionSeleccionada.id}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Error al eliminar"); setPublicaciones(prev => prev.filter(p => p.id !== publicacionSeleccionada.id)); setTotal(prev => prev - 1); @@ -139,31 +188,103 @@ export default function PublicacionesAdmin() { setPublicacionSeleccionada(null); setSnackbarMessage("Publicación borrada"); - setSnackbarSeverity("success"); // Éxito + setSnackbarSeverity("success"); setSnackbarOpen(true); - } catch (error) { - console.error(error); - setSnackbarMessage(`Error al eliminar: ${error.message}`); - setSnackbarSeverity("error"); // Error + setSnackbarMessage(`Error: ${error.message}`); + setSnackbarSeverity("error"); setSnackbarOpen(true); } }; const handlePageChange = (event, value) => { - fetchPublicaciones(value); + setPage(value); }; - const handleCloseSnackbar = () => { - setSnackbarOpen(false); - }; + const handleCloseSnackbar = () => setSnackbarOpen(false); + + // --- CÁLCULO DE ESTADO DEL CHECKBOX MAESTRO --- + const allVisibleIds = publicaciones.map(p => p.id); + const isAllSelected = publicaciones.length > 0 && allVisibleIds.every(id => selectedIds.includes(id)); + const isSomeSelected = publicaciones.length > 0 && allVisibleIds.some(id => selectedIds.includes(id)); return ( <> + {/* --- BARRA DE HERRAMIENTAS --- */} + + + {/* Área de Filtro */} + + setFilterUserId(e.target.value)} + /> + + {activeFilter && ( + <> + + + {/* CHECKBOX MAESTRO ARREGLADO */} + { + e.stopPropagation(); + if (isAllSelected) { + // Desmarcar todos los visibles + setSelectedIds(prev => prev.filter(id => !allVisibleIds.includes(id))); + } else { + // Marcar todos los visibles + setSelectedIds(prev => [...new Set([...prev, ...allVisibleIds])]); + } + }} + > + + + Marcar todo en página + + + + )} + + + {/* Área de Acciones Masivas */} + {selectedIds.length > 0 && ( + + + {selectedIds.length} seleccionadas + + + + )} + + + {/* --- GRILLA DE TARJETAS --- */} {publicaciones.map(pub => { - // Verificamos si está archivada para estilos condicionales - const isArchived = pub.estado === 1; // 1 indica archivada + const isArchived = pub.estado === 1; + const isSelected = selectedIds.includes(pub.id); return ( @@ -172,9 +293,9 @@ export default function PublicacionesAdmin() { width: 320, position: "relative", p: 1, - // Opcional: poner un fondo grisáceo si está archivada bgcolor: isArchived ? '#f5f5f5' : 'white', - opacity: isArchived ? 0.8 : 1 + opacity: isArchived ? 0.8 : 1, + border: isSelected ? `2px solid ${red[500]}` : '1px solid transparent' }} > } action={ - // Indicador visual si está archivada - isArchived && + + { + e.stopPropagation(); + handleToggleSelect(pub.id); + }} + // Aseguramos que aquí SÍ se pueda hacer click + sx={{ p: 0, pointerEvents: 'auto' }} + color="error" + /> + {isArchived && } + } title={pub.titulo} subheader={new Date(pub.fecha_creacion).toLocaleDateString("es-AR")} /> + {pub.primeraImagen && ( )} + ID Pub: {pub.id} + Usuario ID: {pub.id_usuario} Propietario: {pub.usuario?.nombre || "Sin nombre"} Email: {pub.usuario?.email || "Sin email"} Estado: {pub.estado} @@ -208,33 +343,20 @@ export default function PublicacionesAdmin() { - - {/* BOTÓN ARCHIVAR / DESARCHIVAR */} - @@ -253,14 +375,18 @@ export default function PublicacionesAdmin() { })} - {/* Paginación */} + {publicaciones.length === 0 && ( + + No se encontraron publicaciones. + + )} + - {/* Modal de borrado */} setConfirmBorrarPubOpen(false)}> Confirmar eliminación @@ -278,18 +404,13 @@ export default function PublicacionesAdmin() { - {/* Snackbar */} - + {snackbarMessage} From 5fbf7c17597a1b5d6ca74622a993a8ea4948df3a Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Sun, 23 Nov 2025 10:55:50 -0300 Subject: [PATCH 46/96] Agrego boton para eliminr comentario si es tuyo, en una publicacion --- src/components/publicacion/Publicacion.jsx | 338 ++++++++++++--------- 1 file changed, 196 insertions(+), 142 deletions(-) diff --git a/src/components/publicacion/Publicacion.jsx b/src/components/publicacion/Publicacion.jsx index 1cb9fc5..2963f64 100644 --- a/src/components/publicacion/Publicacion.jsx +++ b/src/components/publicacion/Publicacion.jsx @@ -20,14 +20,16 @@ import L from "leaflet"; import ShareIcon from "@mui/icons-material/Share"; import DownloadIcon from "@mui/icons-material/Download"; +// IMPORTAR ICONO DE BORRAR +import DeleteIcon from "@mui/icons-material/Delete"; +import IconButton from "@mui/material/IconButton"; + import TextField from "@mui/material/TextField"; import { getAuth } from "firebase/auth"; import ReporteForm from "../Reportes/Reportes.jsx"; import { useNavigate } from "react-router-dom"; - - // Evitar error de ícono por defecto en Leaflet delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ @@ -53,7 +55,6 @@ const styleModal = { justifyContent: "center", }; - // Slider resizing const AdaptiveHeight = (slider) => { function updateHeight() { @@ -64,7 +65,6 @@ const AdaptiveHeight = (slider) => { slider.on("slideChanged", updateHeight); }; - // Slider arrows function Arrow({ left, onClick, disabled }) { return ( @@ -110,15 +110,14 @@ export default function Publicacion() { const [verDescripcionCompleta, setVerDescripcionCompleta] = useState(false); const [comentarios, setComentarios] = useState([]); const [usuariosComentarios, setUsuariosComentarios] = useState({}); - const [loadingComentarios, setLoadingComentarios] = useState(true); const [nuevoComentario, setNuevoComentario] = useState(""); const [publicandoComentario, setPublicandoComentario] = useState(false); const [errorComentario, setErrorComentario] = useState(null); - const [mostrarReporte, setMostrarReporte] = useState(false); const [mostrarModal, setMostrarModal] = useState(false); const navigate = useNavigate(); const API_URL = import.meta.env.VITE_API_URL; + // Obtener publicación useEffect(() => { axios @@ -133,7 +132,7 @@ export default function Publicacion() { }); }, [id]); - // Obtener usuario + // Obtener usuario dueño del post useEffect(() => { if (publicacion?.id_usuario) { axios @@ -142,21 +141,20 @@ export default function Publicacion() { .catch((err) => console.error("Error al obtener el usuario:", err)); } }, [publicacion]); - //Obtener comentarios de publicacion - useEffect(() => { + + // Obtener comentarios de publicacion + useEffect(() => { if (!id) return; axios .get(`${API_URL}/comentarios/publicacion/${id}`) .then(async (res) => { - const comentarios = res.data; - setComentarios(comentarios); - - // Obtener IDs únicos de usuarios - const idsUnicos = [...new Set(comentarios.map(c => c.id_usuario))]; + const comentariosData = res.data; + setComentarios(comentariosData); - // Obtener datos de cada usuario + const idsUnicos = [...new Set(comentariosData.map(c => c.id_usuario))]; const usuariosMap = {}; + await Promise.all( idsUnicos.map(async (idUsuario) => { try { @@ -224,10 +222,8 @@ export default function Publicacion() { const response = await axios.get(`${API_URL}/pdf/${idPublicacion}`, { responseType: "blob", }); - const blob = new Blob([response.data], { type: "application/pdf" }); const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); link.href = url; link.setAttribute("download", `publicacion_${idPublicacion}.pdf`); @@ -243,17 +239,13 @@ export default function Publicacion() { const compartirPublicacion = (idPublicacion) => { const url = `https://tusitio.com/publicacion/${idPublicacion}`; const title = publicacion?.titulo || "Publicación"; - if (navigator.share) { - navigator - .share({ + navigator.share({ title, text: `Mirá esta publicación: ${title}`, url, - }) - .catch((error) => console.error("Error al compartir:", error)); + }).catch((error) => console.error("Error al compartir:", error)); } else { - // Fallback: Copiar al portapapeles navigator.clipboard.writeText(url).then(() => { alert("Enlace copiado al portapapeles"); }); @@ -269,9 +261,9 @@ export default function Publicacion() { return; } + setPublicandoComentario(true); try { const token = await user.getIdToken(); - const res = await fetch(`${API_URL}/comentarios`, { method: "POST", headers: { @@ -287,25 +279,67 @@ export default function Publicacion() { const data = await res.json(); if (res.ok) { - console.log("Comentario creado:", data); setNuevoComentario(""); - // Recargar comentarios const comentariosRes = await fetch(`${API_URL}/comentarios/publicacion/${id}`); const comentariosData = await comentariosRes.json(); setComentarios(comentariosData); + + // Actualizar mapa de usuarios si es necesario (si soy nuevo comentando) + // Para simplificar, recargamos la pagina o asumimos que ya estabamos cargados si comentamos antes + // Idealmente hacer un fetch del usuario actual y meterlo en usuariosComentarios + const idUsuarioActual = data.id_usuario; // Si el back lo devuelve, si no, recargar + if(idUsuarioActual && !usuariosComentarios[idUsuarioActual]){ + const userRes = await axios.get(`${API_URL}/usuario/${idUsuarioActual}`); + setUsuariosComentarios(prev => ({...prev, [idUsuarioActual]: userRes.data})); + } + } else { throw new Error(data.error || "Error al enviar comentario"); } - } catch (error) { console.error("Error al comentar:", error); - alert("❌ Ocurrió un error al enviar el comentario"); + setErrorComentario("No se pudo enviar el comentario."); + } finally { + setPublicandoComentario(false); } }; + // --- FUNCION PARA ELIMINAR COMENTARIO --- + const borrarComentario = async (idComentario) => { + const auth = getAuth(); + const user = auth.currentUser; + + if (!user) return; + if (!window.confirm("¿Estás seguro de querer borrar este comentario?")) return; + try { + // Obtener token por si el backend requiere auth para borrar (recomendado) + const token = await user.getIdToken(); + + const res = await fetch(`${API_URL}/comentarios/${idComentario}`, { + method: "DELETE", + headers: { + "Authorization": `Bearer ${token}` + } + }); + + if (res.ok) { + // Actualizar estado filtrando el eliminado + setComentarios(prev => prev.filter(c => c.id !== idComentario)); + } else { + const data = await res.json(); + alert(data.error || "No se pudo eliminar el comentario"); + } + } catch (error) { + console.error("Error eliminando comentario:", error); + alert("Error de conexión al eliminar"); + } + }; + // Obtener usuario actual para validaciones de renderizado + const auth = getAuth(); + const currentUser = auth.currentUser; return ( <> @@ -357,7 +391,8 @@ export default function Publicacion() { )} )} - {/* Modal de imágenes */} + + {/* Modal de imágenes (Sin cambios) */} - {/* Usuario */} + {/* Datos del Usuario y Publicación */} {usuario && ( navigate(`/perfil/${usuario.id}`)} /> - {/* Nombre */} )} - - - - - - - + + {/* Botones de Acción */} + + + + + - + {/* Reportar */}
- + setNuevoComentario(e.target.value)} + sx={{ mb: 1 }} + /> + + {errorComentario && ( + + {errorComentario} + + )} + + + - Comentarios - {comentarios.length === 0 ? ( - No hay comentarios aún. - ) : ( - comentarios.map((comentario) => { - const usuario = usuariosComentarios[comentario.id_usuario]; - - return ( - - - - - {usuario?.nombre || "Usuario desconocido"} - - - {new Date(comentario.fecha_creacion).toLocaleString("es-AR", { - dateStyle: "short", - timeStyle: "short", - })} - - {comentario.descripcion} + Comentarios + {comentarios.length === 0 ? ( + No hay comentarios aún. + ) : ( + comentarios.map((comentario) => { + const autorComentario = usuariosComentarios[comentario.id_usuario]; + + // Verificamos si el usuario logueado es el dueño del comentario + // Usamos el email para comparar porque es un dato común seguro + const esMiComentario = currentUser && autorComentario && (currentUser.email === autorComentario.email); + + return ( + + + + + + {autorComentario?.nombre || "Usuario desconocido"} + + + {/* BOTÓN DE ELIMINAR COMENTARIO */} + {esMiComentario && ( + borrarComentario(comentario.id)} + aria-label="eliminar comentario" + sx={{ color: 'text.secondary', '&:hover': { color: 'error.main' } }} + > + + + )} + + + + {new Date(comentario.fecha_creacion).toLocaleString("es-AR", { + dateStyle: "short", + timeStyle: "short", + })} + + {comentario.descripcion} + - - ); - }) - )} - + ); + }) + )} + + ); -} +} \ No newline at end of file From 0bc53533c2d9304bc82db132d90fdda839108302 Mon Sep 17 00:00:00 2001 From: lautarostuve Date: Sun, 23 Nov 2025 12:35:33 -0300 Subject: [PATCH 47/96] Cambios para poder reportar usuario, reportar comentarios y publicaciones. Cambio en el panel de admin en reportes --- .../PanelAdmin/sections/ReportesAdmin.jsx | 173 ++++++++------ src/components/Perfil/publicBanner.jsx | 67 ++++-- src/components/Reportes/Reportes.jsx | 214 +++++++++++------- src/components/publicacion/Publicacion.jsx | 88 ++++--- 4 files changed, 337 insertions(+), 205 deletions(-) diff --git a/src/components/PanelAdmin/sections/ReportesAdmin.jsx b/src/components/PanelAdmin/sections/ReportesAdmin.jsx index 9c1ad7a..f697906 100644 --- a/src/components/PanelAdmin/sections/ReportesAdmin.jsx +++ b/src/components/PanelAdmin/sections/ReportesAdmin.jsx @@ -14,7 +14,7 @@ import Tooltip from '@mui/joy/Tooltip'; import Select from '@mui/joy/Select'; import Option from '@mui/joy/Option'; import Checkbox from '@mui/joy/Checkbox'; -import Chip from '@mui/joy/Chip'; +import Chip from '@mui/joy/Chip'; // Iconos import DeleteIcon from '@mui/icons-material/Delete'; @@ -24,6 +24,9 @@ import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft'; import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import SearchIcon from '@mui/icons-material/Search'; +import ArticleIcon from '@mui/icons-material/Article'; +import PersonIcon from '@mui/icons-material/Person'; +import CommentIcon from '@mui/icons-material/Comment'; import { visuallyHidden } from '@mui/utils'; import { CssVarsProvider } from '@mui/joy/styles'; @@ -44,14 +47,15 @@ function getComparator(order, orderBy) { : (a, b) => -descendingComparator(a, b, orderBy); } -// --- Configuración de Columnas --- +// --- Configuración de Columnas ACTUALIZADA --- const headCells = [ { id: 'id', numeric: true, disablePadding: true, label: 'ID' }, - { id: 'tipo', numeric: false, disablePadding: false, label: 'Tipo' }, - { id: 'descripcion', numeric: false, disablePadding: false, label: 'Descripción' }, - { id: 'id_usuario', numeric: true, disablePadding: false, label: 'Usuario' }, - { id: 'id_publicacion', numeric: true, disablePadding: false, label: 'Pub ID' }, - { id: 'fecha_creacion', numeric: false, disablePadding: false, label: 'Fecha' }, + { id: 'tipo_reporte', numeric: false, disablePadding: false, label: 'Motivo' }, + { id: 'objetivo_tipo', numeric: false, disablePadding: false, label: 'Objetivo' }, // Publicación, Usuario, etc. + { id: 'objetivo_id', numeric: true, disablePadding: false, label: 'ID Ref' }, + { id: 'descripcion', numeric: false, disablePadding: false, label: 'Detalle' }, + { id: 'id_usuario_denunciante', numeric: true, disablePadding: false, label: 'Denunciante' }, + { id: 'fecha', numeric: false, disablePadding: false, label: 'Fecha' }, { id: 'acciones', numeric: false, disablePadding: false, label: 'Acciones' }, ]; @@ -81,12 +85,7 @@ function EnhancedTableHead(props) { {headCell.id !== 'acciones' ? ( : null} endDecorator={!headCell.numeric ? : null} - sx={{ - fontWeight: 'lg', - '& svg': { transition: '0.2s', transform: active && order === 'desc' ? 'rotate(0deg)' : 'rotate(180deg)' }, - '&:hover': { '& svg': { opacity: 1 } }, - }} + sx={{ fontWeight: 'lg' }} > {headCell.label} - {active ? {order === 'desc' ? 'sorted descending' : 'sorted ascending'} : null} ) : ( headCell.label @@ -117,7 +111,7 @@ function EnhancedTableHead(props) { ); } -// --- Barra de Herramientas con Filtro --- +// --- Barra de Herramientas --- function EnhancedTableToolbar({ numSelected, onDeleteSelected, filterPubId, setFilterPubId, onFilterSubmit, onClearFilter }) { return ( 0 ? 'background.level1' : 'background.surface', borderRadius: 'sm', mb: 2, - flexWrap: 'wrap', - gap: 2, borderBottom: '1px solid', borderColor: 'divider' }} @@ -154,7 +146,6 @@ function EnhancedTableToolbar({ numSelected, onDeleteSelected, filterPubId, setF ) : ( - /* SECCIÓN DE FILTROS (IGUAL A COMENTARIOS) */
{ @@ -164,7 +155,7 @@ function EnhancedTableToolbar({ numSelected, onDeleteSelected, filterPubId, setF style={{ display: 'flex', gap: '8px', alignItems: 'flex-end' }} > - Filtrar por ID Publicación + Filtrar por ID Referencia { setLoading(true); try { let url = `${API_URL}/reportes`; - - // Si hay filtro, cambiamos URL if (publicacionId) { url = `${API_URL}/reportes/publicacion/${publicacionId}`; } @@ -219,20 +208,9 @@ export default function ReportesAdmin() { const res = await fetch(url); if (res.ok) { const data = await res.json(); - let lista = Array.isArray(data) ? data : []; - - // --- INYECCIÓN DE ID SI FALTA (Para que el link funcione) --- - if (publicacionId) { - lista = lista.map(reporte => ({ - ...reporte, - id_publicacion: reporte.id_publicacion || publicacionId - })); - } - // ----------------------------------------------------------- - - setRows(lista); + setRows(Array.isArray(data) ? data : []); setPage(0); - setSelected([]); // Limpiar selección al cambiar vista + setSelected([]); } else { console.error("Error obteniendo reportes"); setRows([]); @@ -261,12 +239,12 @@ export default function ReportesAdmin() { const handleClearFilter = () => { setFilterPubId(''); setActiveFilter(null); - fetchReportes(null); // Traer todos + fetchReportes(null); }; - // 2. Eliminar Individual + // Eliminar const handleDelete = async (id) => { - if (!window.confirm("¿Estás seguro de eliminar/cerrar este reporte?")) return; + if (!window.confirm("¿Estás seguro de eliminar este reporte?")) return; try { const res = await fetch(`${API_URL}/reportes/${id}`, { method: 'DELETE' }); if (res.ok) { @@ -280,33 +258,46 @@ export default function ReportesAdmin() { } }; - // 3. Eliminar Masivo const handleDeleteSelected = async () => { if (!window.confirm(`¿Eliminar ${selected.length} reportes?`)) return; - - const promises = selected.map(id => - fetch(`${API_URL}/reportes/${id}`, { method: 'DELETE' }) - ); - + const promises = selected.map(id => fetch(`${API_URL}/reportes/${id}`, { method: 'DELETE' })); try { await Promise.all(promises); setRows(prev => prev.filter(r => !selected.includes(r.id))); setSelected([]); } catch (error) { - console.error("Error en borrado masivo", error); + console.error(error); } }; - // --- Helpers UI --- - const getChipColor = (tipo) => { + // Helpers UI + const getMotivoColor = (tipo) => { if (!tipo) return 'neutral'; const lower = tipo.toLowerCase(); - if (lower.includes('abuso') || lower.includes('fraude') || lower.includes('peligroso')) return 'danger'; - if (lower.includes('spam') || lower.includes('engañoso')) return 'warning'; + if (lower.includes('abuso') || lower.includes('fraude')) return 'danger'; + if (lower.includes('spam')) return 'warning'; return 'primary'; }; - // --- Manejadores de Tabla --- + const getObjetivoIcon = (tipo) => { + switch(tipo) { + case 'Publicación': return ; + case 'Usuario': return ; + case 'Comentario': return ; + default: return null; + } + }; + + const getObjetivoColor = (tipo) => { + switch(tipo) { + case 'Publicación': return 'success'; + case 'Usuario': return 'primary'; + case 'Comentario': return 'warning'; + default: return 'neutral'; + } + }; + + // Manejadores Tabla const handleRequestSort = (event, property) => { const isAsc = orderBy === property && order === 'asc'; setOrder(isAsc ? 'desc' : 'asc'); @@ -338,7 +329,6 @@ export default function ReportesAdmin() { setPage(0); }; - // Paginación const emptyRows = page > 0 ? Math.max(0, (1 + page) * rowsPerPage - rows.length) : 0; function labelDisplayedRows({ from, to, count }) { return `${from}–${to} de ${count !== -1 ? count : `más de ${to}`}`; } const getLabelDisplayedRowsTo = () => rows.length === -1 ? (page + 1) * rowsPerPage : (rowsPerPage === -1 ? rows.length : Math.min(rows.length, (page + 1) * rowsPerPage)); @@ -350,7 +340,7 @@ export default function ReportesAdmin() { variant="outlined" sx={{ width: '100%', - maxWidth: '1100px', + maxWidth: '1200px', // Más ancho para que quepa todo mx: 'auto', boxShadow: 'sm', borderRadius: 'sm', @@ -361,7 +351,6 @@ export default function ReportesAdmin() { {row.id} + + {/* MOTIVO */} - - {row.tipo} + + {row.tipo_reporte} + + {/* TIPO OBJETIVO (Publicacion/Usuario) */} - - {row.descripcion} + + {row.objetivo_tipo} + + + + {/* ID REF + LINK */} + + {row.objetivo_tipo === 'Publicación' && ( + + {row.objetivo_id} + + )} + {row.objetivo_tipo === 'Usuario' && ( + + {row.objetivo_id} + + )} + {row.objetivo_tipo === 'Comentario' && ( + + {row.objetivo_id} + + )} + + + {/* DESCRIPCIÓN */} + + + {row.descripcion || "-"} - {row.id_usuario || 'Anon'} + + {/* DENUNCIANTE */} - - {row.id_publicacion} + + User {row.id_usuario_denunciante} + {/* Usamos fecha_creacion y formateamos incluyendo hora */} @@ -431,8 +463,9 @@ export default function ReportesAdmin() { : '-'} + - + { e.stopPropagation(); handleDelete(row.id); @@ -446,13 +479,13 @@ export default function ReportesAdmin() { })} {rows.length === 0 && !loading && ( - No hay reportes. + No hay reportes. )} - {emptyRows > 0 && } + {emptyRows > 0 && } - + Filas: diff --git a/src/components/Perfil/publicBanner.jsx b/src/components/Perfil/publicBanner.jsx index f09367b..6ae83df 100644 --- a/src/components/Perfil/publicBanner.jsx +++ b/src/components/Perfil/publicBanner.jsx @@ -1,14 +1,23 @@ // publicBanner.jsx es la portada que vemos de un perfil público import React, { useEffect, useState } from 'react'; -import './cbanner.css'; // tu CSS para estilos -import { obtenerUsuarioPorId } from '../../services/perfilService' +import './cbanner.css'; +import { obtenerUsuarioPorId } from '../../services/perfilService'; + +// --- IMPORTACIONES NUEVAS --- import OutlinedFlagIcon from '@mui/icons-material/OutlinedFlag'; import MailIcon from '@mui/icons-material/Mail'; +import IconButton from '@mui/material/IconButton'; // Para que el icono sea un botón clickeable +import Tooltip from '@mui/material/Tooltip'; // Para mostrar "Reportar" al pasar el mouse +import ReporteForm from '../Reportes/Reportes.jsx'; // Asegúrate que la ruta sea correcta +// ---------------------------- -const publicBanner = ({ userId }) => { +const PublicBanner = ({ userId }) => { // Cambié a PascalCase (Buenas prácticas React) const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + + // Estado para controlar el modal de reporte + const [mostrarModalReporte, setMostrarModalReporte] = useState(false); useEffect(() => { if (!userId) return; @@ -33,24 +42,48 @@ const publicBanner = ({ userId }) => { return ( <> -
- {user?.nombre - -
- {user?.nombre || "Usuario"} -
- - +
+ {user?.nombre + +
+ {user?.nombre || "Usuario"} + +
+ {/* Botón de Mensaje (Ejemplo) */} + + + + + + + {/* BOTÓN DE REPORTAR */} + + setMostrarModalReporte(true)} + sx={{ '&:hover': { color: 'warning.main' } }} // Se pone naranja al pasar el mouse + > + + + +
-
+ {/* MODAL DE REPORTE */} + {mostrarModalReporte && ( + setMostrarModalReporte(false)} + /> + )} ); }; -export default publicBanner; +export default PublicBanner; \ No newline at end of file diff --git a/src/components/Reportes/Reportes.jsx b/src/components/Reportes/Reportes.jsx index b41b067..f3ad91b 100644 --- a/src/components/Reportes/Reportes.jsx +++ b/src/components/Reportes/Reportes.jsx @@ -1,30 +1,45 @@ import React, { useState } from "react"; +import { + Box, + Button, + Typography, + TextField, + FormControl, + FormLabel, + RadioGroup, + FormControlLabel, + Radio, + IconButton, + Alert, + CircularProgress +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; import { getAuth } from "firebase/auth"; -export default function ReporteForm({ idPublicacion, idUsuario, onClose }) { - const [tipo, setTipo] = useState(""); +const API_URL = import.meta.env.VITE_API_URL; + +export default function ReporteForm({ idPublicacion, idComentario, idUsuario, onClose }) { + const [tipo, setTipo] = useState("Spam"); // Valor por defecto para evitar clic extra const [descripcion, setDescripcion] = useState(""); const [loading, setLoading] = useState(false); const [mensaje, setMensaje] = useState(null); - const API_URL = import.meta.env.VITE_API_URL; + const [error, setError] = useState(null); const handleSubmit = async (e) => { e.preventDefault(); + setLoading(true); + setMensaje(null); + setError(null); + const auth = getAuth(); const user = auth.currentUser; if (!user) { - setMensaje("Debes iniciar sesión para reportar contenido."); - return; - } - if (!tipo) { - setMensaje("Selecciona un tipo de reporte."); + setError("Debes iniciar sesión para reportar contenido."); + setLoading(false); return; } - setLoading(true); - setMensaje(null); - try { const token = await user.getIdToken(); @@ -35,103 +50,132 @@ export default function ReporteForm({ idPublicacion, idUsuario, onClose }) { Authorization: `Bearer ${token}`, }, body: JSON.stringify({ - id_publicacion: idPublicacion, - id_usuario: idUsuario, + // Enviamos null si no está definido, para cumplir con el contrato del backend + id_publicacion: idPublicacion || null, + id_comentario: idComentario || null, + id_usuario_reportado: idUsuario || null, // Mapeo correcto descripcion, tipo, }), }); + const data = await response.json(); + if (!response.ok) { - throw new Error("Error al enviar reporte"); + throw new Error(data.error || "Error al enviar reporte"); } - setMensaje("Reporte enviado con éxito."); - setDescripcion(""); - setTipo(""); + setMensaje("Reporte enviado con éxito. Gracias por tu colaboración."); + // Cerrar modal después de un breve delay para que el usuario lea el mensaje + setTimeout(() => { + onClose(); + }, 2000); + } catch (err) { - setMensaje("❌ " + err.message); + setError(err.message); } finally { setLoading(false); } }; return ( -
-
- -

Denunciar

-

Selecciona el motivo del reporte:

+ + e.stopPropagation()} + > + + + + + + {idComentario ? "Reportar Comentario" : "Reportar Publicación"} + + + + Selecciona el motivo del reporte. Tu identidad se mantendrá anónima para el usuario reportado. + - - - - - - - - -