[REPO REFACTOR]: changed to a better git repository structure with branches

This commit is contained in:
2025-11-01 03:57:48 +01:00
parent 85f98f66b4
commit 3dd17352aa
156 changed files with 15648 additions and 0 deletions

14
src/api/axiosInstance.js Normal file
View File

@@ -0,0 +1,14 @@
import axios from "axios";
const createAxiosInstance = (baseURL, token) => {
const instance = axios.create({
baseURL,
headers: {
...(token && { Authorization: `Bearer ${token}` }),
},
});
return instance;
};
export default createAxiosInstance;

View File

@@ -0,0 +1,92 @@
import { useState, useRef, useEffect, cloneElement } from 'react';
import { Button } from 'react-bootstrap';
import { AnimatePresence, motion as _motion } from 'framer-motion';
import '../css/AnimatedDropdown.css';
const AnimatedDropdown = ({
trigger,
icon,
variant = "secondary",
className = "",
buttonStyle = "",
show,
onToggle,
onMouseEnter,
onMouseLeave,
children
}) => {
const isControlled = show !== undefined;
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const dropdownRef = useRef(null);
const actualOpen = isControlled ? show : open;
const toggle = () => {
const newState = !actualOpen;
if (!isControlled) setOpen(newState);
onToggle?.(newState);
};
useEffect(() => {
const handleClickOutside = (e) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target) &&
!triggerRef.current?.contains(e.target)
) {
if (!isControlled) setOpen(false);
onToggle?.(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isControlled, onToggle]);
const triggerElement = trigger
? (typeof trigger === "function"
? trigger({ onClick: toggle, ref: triggerRef })
: cloneElement(trigger, { onClick: toggle, ref: triggerRef }))
: (
<Button
ref={triggerRef}
variant={variant}
className={`circle-btn ${buttonStyle}`}
onClick={toggle}
>
{icon}
</Button>
);
const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
return (
<div
className={`position-relative d-inline-block`}
onClick={toggle}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
ref={triggerRef}
>
{triggerElement}
<AnimatePresence>
{actualOpen && (
<_motion.div
ref={dropdownRef}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.15 }}
className={dropdownClasses}
>
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
</_motion.div>
)}
</AnimatePresence>
</div>
);
};
export default AnimatedDropdown;

View File

@@ -0,0 +1,122 @@
import { useState, useRef, useEffect, cloneElement } from 'react';
import { Button } from 'react-bootstrap';
import { AnimatePresence, motion as _motion } from 'framer-motion';
import '../css/AnimatedDropdown.css';
const AnimatedDropend = ({
trigger,
icon,
variant = "secondary",
className = "",
buttonStyle = "",
show,
onToggle,
onMouseEnter,
onMouseLeave,
children
}) => {
const isControlled = show !== undefined;
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const dropdownRef = useRef(null);
const actualOpen = isControlled ? show : open;
const toggle = (forceValue) => {
const newState = typeof forceValue === "boolean" ? forceValue : !actualOpen;
if (!isControlled) setOpen(newState);
onToggle?.(newState);
};
useEffect(() => {
const handleClickOutside = (e) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target) &&
!triggerRef.current?.contains(e.target)
) {
if (!isControlled) setOpen(false);
onToggle?.(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isControlled, onToggle]);
const handleMouseEnter = () => {
if (!isControlled) setOpen(true);
onToggle?.(true);
onMouseEnter?.();
};
const handleMouseLeave = () => {
if (!isControlled) setOpen(false);
onToggle?.(false);
onMouseLeave?.();
};
const triggerElement = trigger
? (typeof trigger === "function"
? trigger({
onClick: e => {
e.stopPropagation();
toggle();
},
ref: triggerRef
})
: cloneElement(trigger, {
onClick: e => {
e.stopPropagation();
toggle();
},
ref: triggerRef
}))
: (
<Button
ref={triggerRef}
variant={variant}
className={`circle-btn ${buttonStyle}`}
onClick={toggle}
>
{icon}
</Button>
);
const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
return (
<div
className="position-relative d-inline-block dropend"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={triggerRef}
>
{triggerElement}
<AnimatePresence>
{actualOpen && (
<_motion.div
ref={dropdownRef}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.15 }}
className={dropdownClasses}
style={{
position: 'absolute',
top: '0',
left: '100%',
zIndex: 1000,
whiteSpace: 'nowrap'
}}
>
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
</_motion.div>
)}
</AnimatePresence>
</div>
);
};
export default AnimatedDropend;

View File

@@ -0,0 +1,210 @@
import { useState, useEffect } from 'react';
import { Card, Button, Form } from 'react-bootstrap';
import AnimatedDropdown from '../../components/AnimatedDropdown';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEdit, faTrash, faEllipsisVertical } from '@fortawesome/free-solid-svg-icons';
import '../../css/AnuncioCard.css';
import { renderErrorAlert } from '../../util/alertHelpers';
import {
EditorProvider,
Editor,
} from 'react-simple-wysiwyg';
import {
Toolbar,
Separator,
BtnBold,
BtnItalic,
BtnUnderline,
BtnStrikeThrough,
BtnNumberedList,
BtnBulletList,
BtnLink,
BtnClearFormatting,
} from 'react-simple-wysiwyg';
import DOMPurify from 'dompurify';
const PRIORITY_CONFIG = {
0: { label: 'BAJA', className: 'text-success' },
1: { label: 'MEDIA', className: 'text-warning' },
2: { label: 'ALTA', className: 'text-danger' },
};
const formatDateTime = (iso) => {
const date = new Date(iso);
return {
date: date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: 'numeric' }),
time: date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit', hour12: false }),
};
};
const AnuncioCard = ({ anuncio, isNew = false, onCreate, onUpdate, onDelete, onCancel, error, onClearError }) => {
const createMode = isNew;
const [editMode, setEditMode] = useState(createMode);
const [showFullBody, setShowFullBody] = useState(false);
const [formData, setFormData] = useState({
body: anuncio.body || '',
priority: anuncio.priority ?? 1,
published_by: JSON.parse(localStorage.getItem('user'))?.user_id,
});
useEffect(() => {
if (!editMode) {
setFormData({
body: anuncio.body || '',
priority: anuncio.priority ?? 1,
published_by: JSON.parse(localStorage.getItem('user'))?.user_id,
});
}
}, [anuncio, editMode]);
const handleEdit = () => {
if (onClearError) onClearError();
setEditMode(true);
};
const handleDelete = () => typeof onDelete === 'function' && onDelete(anuncio.announce_id);
const handleCancel = () => {
if (onClearError) onClearError();
if (createMode && onCancel) return onCancel();
setEditMode(false);
};
const handleSave = () => {
if (onClearError) onClearError();
const sanitizedBody = DOMPurify.sanitize(formData.body);
formData.body = sanitizedBody;
const updated = { ...anuncio, ...formData };
if (createMode && typeof onCreate === 'function') return onCreate(updated);
if (typeof onUpdate === 'function') return onUpdate(updated, anuncio.announce_id);
};
const handleChange = (field, value) => setFormData((prev) => ({ ...prev, [field]: value }));
const { date, time } = formatDateTime(anuncio.created_at);
const priorityInfo = PRIORITY_CONFIG[formData.priority] || PRIORITY_CONFIG[1];
const isLongBody = formData.body.length > 300;
const displayBody = isLongBody && !showFullBody
? `${formData.body.slice(0, 300)}...`
: formData.body;
const insertImage = () => {
const url = prompt('Introduce la URL de la imagen:');
if (url) {
const imgHTML = `<img src="${url}" alt="imagen" style="max-width: 100%;" />`;
handleChange('body', formData.body + imgHTML);
}
};
return (
<Card className="anuncio-card rounded-4 border-0 shadow-sm mb-4">
<Card.Header className="d-flex justify-content-between align-items-center rounded-top-4 px-3 py-2">
<div className="d-flex flex-column">
<span className="fw-bold">📢&emsp;Anuncio #{anuncio.announce_id}</span>
<small className="muted">
Publicado el {date} a las {time} por{' '}
<span className="fw-semibold">#{anuncio.published_by}</span>
</small>
</div>
{!createMode && !editMode && (
<AnimatedDropdown
className="end-0"
buttonStyle="bg-transparent border-0"
icon={<FontAwesomeIcon icon={faEllipsisVertical} className="fa-xl" />}
>
{({ closeDropdown }) => (
<>
<div className="dropdown-item d-flex align-items-center" onClick={() => { handleEdit(); closeDropdown(); }}>
<FontAwesomeIcon icon={faEdit} className="me-2" />Editar
</div>
<hr className="dropdown-divider" />
<div className="dropdown-item d-flex align-items-center text-danger" onClick={() => { handleDelete(); closeDropdown(); }}>
<FontAwesomeIcon icon={faTrash} className="me-2" />Eliminar
</div>
</>
)}
</AnimatedDropdown>
)}
</Card.Header>
<Card.Body className="py-3">
{(editMode || createMode) && renderErrorAlert(error)}
{editMode || createMode ? (
<EditorProvider>
<Editor
value={formData.body}
onChange={(e) => handleChange('body', e.target.value)}
containerProps={{ className: 'mb-2' }}
>
<Toolbar>
<BtnBold />
<BtnItalic />
<BtnUnderline />
<BtnStrikeThrough />
<BtnClearFormatting />
<Separator />
<BtnNumberedList />
<BtnBulletList />
<Separator />
<BtnLink />
<button
type="button"
onClick={insertImage}
className="btn"
title="Insertar imagen desde URL"
>
🖼
</button>
</Toolbar>
</Editor>
</EditorProvider>
) : (
<>
<div className="mb-2" dangerouslySetInnerHTML={{ __html: displayBody }} />
{isLongBody && (
<Button variant='info'
className="fw-medium text-dark mt-2"
onClick={(e) => {
e.preventDefault();
setShowFullBody((prev) => !prev);
}}
>
{showFullBody ? 'Leer menos' : 'Leer más'}
</Button>
)}
</>
)}
{editMode && (
<Form.Select
className="mb-2 themed-input"
value={formData.priority}
onChange={(e) => handleChange('priority', parseInt(e.target.value))}
>
<option value={0}>Prioridad Baja</option>
<option value={1}>Prioridad Media</option>
<option value={2}>Prioridad Alta</option>
</Form.Select>
)}
{editMode && (
<div className="d-flex justify-content-end gap-2">
<Button variant="secondary" size="sm" onClick={handleCancel}>Cancelar</Button>
<Button variant="primary" size="sm" onClick={handleSave}>Guardar</Button>
</div>
)}
</Card.Body>
{!editMode && (
<Card.Footer className="priority-footer text-center rounded-bottom-4 fw-medium py-2">
Prioridad: <span className={`fw-bold ${priorityInfo.className}`}>{priorityInfo.label}</span>
</Card.Footer>
)}
</Card>
);
};
export default AnuncioCard;

View File

@@ -0,0 +1,108 @@
import PropTypes from 'prop-types';
const AnunciosFilter = ({ filters, onChange }) => {
const handleCheckboxChange = (key) => {
const updated = { ...filters, [key]: !filters[key] };
const allPrioridades = ['baja', 'media', 'alta'];
const allFechas = ['ultimos7', 'esteMes'];
updated.todos = (
allPrioridades.every(p => updated[p]) &&
allFechas.every(f => updated[f])
);
onChange(updated);
};
const handleTodosChange = () => {
const newValue = !filters.todos;
onChange({
todos: newValue,
baja: newValue,
media: newValue,
alta: newValue,
ultimos7: newValue,
esteMes: newValue
});
};
return (
<>
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="todosCheck"
className="me-2"
checked={filters.todos}
onChange={handleTodosChange}
/>
<label htmlFor="todosCheck" className="m-0">Mostrar Todos</label>
</div>
<hr className="dropdown-divider" />
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="bajaCheck"
className="me-2"
checked={filters.baja}
onChange={() => handleCheckboxChange('baja')}
/>
<label htmlFor="bajaCheck" className="m-0">Prioridad Baja</label>
</div>
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="mediaCheck"
className="me-2"
checked={filters.media}
onChange={() => handleCheckboxChange('media')}
/>
<label htmlFor="mediaCheck" className="m-0">Prioridad Media</label>
</div>
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="altaCheck"
className="me-2"
checked={filters.alta}
onChange={() => handleCheckboxChange('alta')}
/>
<label htmlFor="altaCheck" className="m-0">Prioridad Alta</label>
</div>
<hr className="dropdown-divider" />
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="ultimos7Check"
className="me-2"
checked={filters.ultimos7}
onChange={() => handleCheckboxChange('ultimos7')}
/>
<label htmlFor="ultimos7Check" className="m-0">Últimos 7 días</label>
</div>
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="esteMesCheck"
className="me-2"
checked={filters.esteMes}
onChange={() => handleCheckboxChange('esteMes')}
/>
<label htmlFor="esteMesCheck" className="m-0">Este mes</label>
</div>
</>
);
};
AnunciosFilter.propTypes = {
filters: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired
};
export default AnunciosFilter;

89
src/components/App.jsx Normal file
View File

@@ -0,0 +1,89 @@
import Header from './Header'
import NavBar from './NavBar/NavBar'
import Footer from './Footer'
import { Route, Routes, useLocation } from 'react-router-dom'
import ProtectedRoute from './Auth/ProtectedRoute.jsx'
import useSessionRenewal from '../hooks/useSessionRenewal'
import Home from '../pages/Home'
import Socios from '../pages/Socios'
import Ingresos from '../pages/Ingresos'
import Gastos from '../pages/Gastos'
import Balance from '../pages/Balance'
import Login from '../pages/Login'
import Solicitudes from '../pages/Solicitudes'
import Anuncios from '../pages/Anuncios'
import ListaEspera from '../pages/ListaEspera'
import Building from '../pages/Building'
import Documentacion from '../pages/Documentacion'
import { CONSTANTS } from '../util/constants'
import Perfil from '../pages/Perfil.jsx'
import Correo from '../pages/Correo.jsx'
function App() {
const { modal: sessionModal } = useSessionRenewal();
const routesWithFooter = ["/", "/lista-espera", "/login", "/gestion/socios", "/gestion/ingresos", "/gestion/gastos", "/gestion/balance"];
return (
<>
<Header />
<NavBar />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/lista-espera" element={<ListaEspera />} />
<Route path="/login" element={<Login />} />
<Route path="/gestion/socios" element={
<ProtectedRoute minimumRoles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
<Socios />
</ProtectedRoute>
} />
<Route path="/gestion/ingresos" element={
<ProtectedRoute minimumRoles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
<Ingresos />
</ProtectedRoute>
} />
<Route path="/gestion/gastos" element={
<ProtectedRoute minimumRoles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
<Gastos />
</ProtectedRoute>
} />
<Route path="/gestion/balance" element={
<ProtectedRoute minimumRoles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
<Balance />
</ProtectedRoute>
} />
<Route path="/documentacion" element={
<ProtectedRoute>
<Documentacion />
</ProtectedRoute>
} />
<Route path="/anuncios" element={
<ProtectedRoute>
<Anuncios />
</ProtectedRoute>
} />
<Route path="/gestion/solicitudes" element={
<ProtectedRoute minimumRoles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
<Solicitudes />
</ProtectedRoute>
} />
<Route path="/perfil" element={
<ProtectedRoute>
<Perfil />
</ProtectedRoute>
} />
<Route path="/correo" element={
<ProtectedRoute minimumRoles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
<Correo />
</ProtectedRoute>
} />
<Route path="/*" element={<Building />} />
</Routes>
{routesWithFooter.includes(useLocation().pathname) ? <Footer /> : null}
{sessionModal}
</>
)
}
export default App

View File

@@ -0,0 +1,8 @@
import { useAuth } from "../../hooks/useAuth.js";
const IfAuthenticated = ({ children }) => {
const { authStatus } = useAuth();
return authStatus === "authenticated" ? children : null;
};
export default IfAuthenticated;

View File

@@ -0,0 +1,8 @@
import { useAuth } from "../../hooks/useAuth.js";
const IfNotAuthenticated = ({ children }) => {
const { authStatus } = useAuth();
return authStatus === "unauthenticated" ? children : null;
};
export default IfNotAuthenticated;

View File

@@ -0,0 +1,13 @@
import { useAuth } from "../../hooks/useAuth.js";
const IfRole = ({ roles, children }) => {
const { user, authStatus } = useAuth();
if (authStatus !== "authenticated") return null;
const userRole = user?.role;
return roles.includes(userRole) ? children : null;
};
export default IfRole;

View File

@@ -0,0 +1,120 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUser } from '@fortawesome/free-solid-svg-icons';
import { Form, Button, Alert, FloatingLabel, Row, Col } from 'react-bootstrap';
import PasswordInput from './PasswordInput.jsx';
import { useContext, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AuthContext } from "../../context/AuthContext.jsx";
import CustomContainer from '../CustomContainer.jsx';
import ContentWrapper from '../ContentWrapper.jsx';
import '../../css/LoginForm.css';
const LoginForm = () => {
const { login, error } = useContext(AuthContext);
const navigate = useNavigate();
const [formState, setFormState] = useState({
emailOrUserName: "",
password: "",
keepLoggedIn: false
});
const handleChange = (e) => {
const { name, value } = e.target;
setFormState((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formState.emailOrUserName);
const loginBody = {
password: formState.password,
keepLoggedIn: Boolean(formState.keepLoggedIn),
};
if (isEmail) {
loginBody.email = formState.emailOrUserName;
} else {
loginBody.userName = formState.emailOrUserName;
}
try {
await login(loginBody);
navigate("/");
} catch (err) {
console.error("Error de login:", err.message);
}
};
return (
<CustomContainer>
<ContentWrapper>
<div className="login-card card shadow p-5 rounded-5 mx-auto col-12 col-md-8 col-lg-6 col-xl-5 d-flex flex-column gap-4">
<h1 className="text-center">Inicio de sesión</h1>
<Form className="d-flex flex-column gap-5" onSubmit={handleSubmit}>
<div className="d-flex flex-column gap-3">
<FloatingLabel
controlId="floatingUsuario"
label={
<>
<FontAwesomeIcon icon={faUser} className="me-2" />
Usuario o Email
</>
}
>
<Form.Control
type="text"
placeholder=""
name="emailOrUserName"
value={formState.emailOrUserName}
onChange={handleChange}
className="rounded-4"
/>
</FloatingLabel>
<PasswordInput
value={formState.password}
onChange={handleChange}
name="password"
/>
<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center gap-2">
<Form.Check
type="checkbox"
name="keepLoggedIn"
label="Mantener sesión iniciada"
className="text-secondary"
value={formState.keepLoggedIn}
onChange={(e) => { formState.keepLoggedIn = e.target.checked; setFormState({ ...formState }) }}
/>
{/*<Link disabled to="#" className="muted">
Olvidé mi contraseña
</Link>*/}
</div>
</div>
{error && (
<Alert variant="danger" className="text-center py-2 mb-0">
{error}
</Alert>
)}
<div className="text-center">
<Button type="submit" className="w-75 padding-4 rounded-4 border-0 shadow-sm login-button">
Iniciar sesión
</Button>
</div>
</Form>
</div>
</ContentWrapper>
</CustomContainer>
);
};
export default LoginForm;

View File

@@ -0,0 +1,48 @@
import { useState } from 'react';
import { Form, FloatingLabel, Button } from 'react-bootstrap';
import '../../css/PasswordInput.css';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEye, faEyeSlash, faKey } from '@fortawesome/free-solid-svg-icons';
const PasswordInput = ({ value, onChange, name = "password" }) => {
const [show, setShow] = useState(false);
const toggleShow = () => setShow(prev => !prev);
return (
<div className="position-relative w-100">
<FloatingLabel
controlId="passwordInput"
label={
<>
<FontAwesomeIcon icon={faKey} className="me-2" />
Contraseña
</>
}
>
<Form.Control
type={show ? "text" : "password"}
name={name}
value={value}
placeholder=""
onChange={onChange}
className="rounded-4 pe-5"
/>
</FloatingLabel>
<Button
variant="link"
className="show-button position-absolute end-0 top-50 translate-middle-y me-2"
onClick={toggleShow}
aria-label="Mostrar contraseña"
tabIndex={-1}
style={{ zIndex: 2 }}
>
<FontAwesomeIcon icon={show ? faEyeSlash : faEye} className='fa-lg' />
</Button>
</div>
);
};
export default PasswordInput;

View File

@@ -0,0 +1,18 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "../../hooks/useAuth.js";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
const ProtectedRoute = ({ minimumRoles, children }) => {
const { authStatus } = useAuth();
if (authStatus === "checking") return <FontAwesomeIcon icon={faSpinner} />; // o un loader si quieres
if (authStatus === "unauthenticated") return <Navigate to="/login" replace />;
if (authStatus === "authenticated" && minimumRoles) {
const userRole = JSON.parse(localStorage.getItem("user"))?.role;
if (!minimumRoles.includes(userRole)) return <Navigate to="/" replace />;
}
return children;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,114 @@
import { Document, Page, Text, View, StyleSheet, Font, Image } from '@react-pdf/renderer';
import { format } from 'date-fns';
Font.register({
family: 'Open Sans',
fonts: [{ src: '/fonts/OpenSans.ttf', fontWeight: 'normal' }]
});
const styles = StyleSheet.create({
page: {
padding: 25,
fontSize: 12,
fontFamily: 'Open Sans',
backgroundColor: '#F8F9FA',
},
headerContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 25,
justifyContent: 'left',
},
logo: {
width: 60,
height: 60,
},
headerText: {
flexDirection: 'column',
marginLeft: 25,
},
header: {
fontSize: 26,
fontWeight: 'bold',
color: '#2C3E50',
},
subHeader: {
fontSize: 12,
marginTop: 5,
color: '#34495E'
},
sectionTitle: {
fontSize: 16,
marginTop: 10,
marginBottom: 5,
color: '#2C3E50',
fontWeight: 'bold',
},
row: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 4,
borderBottomWidth: 1,
borderBottomColor: '#D5D8DC',
},
label: {
fontSize: 11,
color: '#566573',
},
value: {
fontSize: 11,
fontWeight: 'bold',
color: '#2C3E50'
}
});
const formatCurrency = (value) =>
new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(value);
export const BalancePDF = ({ balance }) => {
const {
initial_bank,
initial_cash,
total_bank_expenses,
total_cash_expenses,
total_bank_incomes,
total_cash_incomes,
created_at
} = balance;
const final_bank = initial_bank + total_bank_incomes - total_bank_expenses;
const final_cash = initial_cash + total_cash_incomes - total_cash_expenses;
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.headerContainer}>
<Image src="/images/logo.png" style={styles.logo} />
<View style={styles.headerText}>
<Text style={styles.header}>Informe de Balance</Text>
<Text style={styles.subHeader}>Asociación Huertos La Salud - Bellavista Generado el {new Date().toLocaleDateString()} a las {new Date().toLocaleTimeString()}</Text>
</View>
</View>
<Text style={styles.sectionTitle}>Banco</Text>
<View style={styles.row}><Text style={styles.label}>Saldo inicial</Text><Text style={styles.value}>{formatCurrency(initial_bank)}</Text></View>
<View style={styles.row}><Text style={styles.label}>Ingresos</Text><Text style={styles.value}>{formatCurrency(total_bank_incomes)}</Text></View>
<View style={styles.row}><Text style={styles.label}>Gastos</Text><Text style={styles.value}>{formatCurrency(total_bank_expenses)}</Text></View>
<Text style={styles.sectionTitle}>Caja</Text>
<View style={styles.row}><Text style={styles.label}>Saldo inicial</Text><Text style={styles.value}>{formatCurrency(initial_cash)}</Text></View>
<View style={styles.row}><Text style={styles.label}>Ingresos</Text><Text style={styles.value}>{formatCurrency(total_cash_incomes)}</Text></View>
<View style={styles.row}><Text style={styles.label}>Gastos</Text><Text style={styles.value}>{formatCurrency(total_cash_expenses)}</Text></View>
<Text style={styles.sectionTitle}>Total</Text>
<View style={styles.row}><Text style={styles.label}>Banco</Text><Text style={styles.value}>{formatCurrency(final_bank)}</Text></View>
<View style={styles.row}><Text style={styles.label}>Caja</Text><Text style={styles.value}>{formatCurrency(final_cash)}</Text></View>
<View style={styles.row}><Text style={styles.label}>Total</Text><Text style={styles.value}>{formatCurrency(final_bank + final_cash)}</Text></View>
<Text style={[styles.label, { marginTop: 20 }]}>
Última actualización: {format(new Date(created_at), 'dd/MM/yyyy HH:mm')}
</Text>
</Page>
</Document>
);
};

View File

@@ -0,0 +1,93 @@
import { useState } from 'react';
import { Card, Button, Row, Col, Container } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faPiggyBank,
faCoins,
faArrowDown,
faArrowUp,
faPrint,
faClock
} from '@fortawesome/free-solid-svg-icons';
import PDFModal from '../PDFModal';
import { BalancePDF } from './BalancePDF';
import { format } from 'date-fns';
import '../../css/BalanceReport.css';
const formatCurrency = (value) =>
new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(value);
const BalanceReport = ({ balance }) => {
const [showPDF, setShowPDF] = useState(false);
const showPDFModal = () => setShowPDF(true);
const closePDFModal = () => setShowPDF(false);
const {
initial_bank,
initial_cash,
total_bank_expenses,
total_cash_expenses,
total_bank_incomes,
total_cash_incomes,
created_at
} = balance;
const final_bank = initial_bank + total_bank_incomes - total_bank_expenses;
const final_cash = initial_cash + total_cash_incomes - total_cash_expenses;
return (
<>
<Container className="my-4">
<Card className="balance-report px-4 py-5">
<Row className="align-items-center justify-content-between mb-4">
<Col xs="12" md="auto" className="text-center text-md-start mb-3 mb-md-0">
<h1 className="report-title m-0">📊 Informe de Balance</h1>
</Col>
<Col xs="12" md="auto" className="text-center text-md-end">
<Button className="print-btn" onClick={showPDFModal}>
<FontAwesomeIcon icon={faPrint} className="me-2" />
Imprimir PDF
</Button>
</Col>
</Row>
<Row className="gy-4">
<Col md={6}>
<div className="balance-box">
<h4><FontAwesomeIcon icon={faPiggyBank} className="me-2" />Banco</h4>
<p>Saldo inicial: <span className="balance-value">{formatCurrency(initial_bank)}</span></p>
<p><FontAwesomeIcon icon={faArrowUp} className="me-1 text-success" />Ingresos: <span className="balance-value">{formatCurrency(total_bank_incomes)}</span></p>
<p><FontAwesomeIcon icon={faArrowDown} className="me-1 text-danger" />Gastos: <span className="balance-value">{formatCurrency(total_bank_expenses)}</span></p>
<p className="fw-bold mt-3">💰 Saldo final: {formatCurrency(final_bank)}</p>
</div>
</Col>
<Col md={6}>
<div className="balance-box">
<h4><FontAwesomeIcon icon={faCoins} className="me-2" />Caja</h4>
<p>Saldo inicial: <span className="balance-value">{formatCurrency(initial_cash)}</span></p>
<p><FontAwesomeIcon icon={faArrowUp} className="me-1 text-success" />Ingresos: <span className="balance-value">{formatCurrency(total_cash_incomes)}</span></p>
<p><FontAwesomeIcon icon={faArrowDown} className="me-1 text-danger" />Gastos: <span className="balance-value">{formatCurrency(total_cash_expenses)}</span></p>
<p className="fw-bold mt-3">💵 Saldo final: {formatCurrency(final_cash)}</p>
</div>
</Col>
</Row>
<Row className="mt-4">
<Col className="text-end balance-timestamp">
<FontAwesomeIcon icon={faClock} className="me-2" />
Última actualización: {format(new Date(created_at), 'dd/MM/yyyy HH:mm')}
</Col>
</Row>
</Card>
</Container>
<PDFModal show={showPDF} onClose={closePDFModal} title="Vista previa del PDF">
<BalancePDF balance={balance} />
</PDFModal>
</>
);
};
export default BalanceReport;

View File

@@ -0,0 +1,15 @@
import PropTypes from 'prop-types';
const ContentWrapper = ({ children }) => {
return (
<div className="container-xl">
{children}
</div>
);
}
ContentWrapper.propTypes = {
children: PropTypes.node.isRequired,
}
export default ContentWrapper;

View File

@@ -0,0 +1,105 @@
import { useState, useEffect } from 'react';
import { Modal, Button, Form } from 'react-bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
export default function ComposeMailModal({ isOpen, onClose, onSend }) {
const [formData, setFormData] = useState({
from: '',
to: [],
subject: '',
content: '',
attachments: []
});
useEffect(() => {
if (isOpen) {
const user = JSON.parse(localStorage.getItem("user"));
const email = user?.email || '';
setFormData((prev) => ({ ...prev, from: email }));
}
}, [isOpen]);
const handleChange = (e) => {
const { name, value } = e.target;
if (name === 'to') {
const toArray = value
.split(',')
.map((email) => email.trim())
.filter((email) => email !== '');
setFormData({ ...formData, to: toArray });
} else {
setFormData({ ...formData, [name]: value });
}
};
const handleFileChange = (e) => {
setFormData({ ...formData, attachments: Array.from(e.target.files) });
};
const handleSubmit = (e) => {
e.preventDefault();
onSend(formData);
onClose();
};
return (
<Modal show={isOpen} onHide={onClose} centered>
<Modal.Header closeButton>
<Modal.Title>Redactar Correo</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3" controlId="formTo">
<Form.Label>Para:</Form.Label>
<Form.Control
type="text"
name="to"
value={formData.to.join(', ')}
onChange={handleChange}
placeholder="Separar múltiples correos con comas"
required
/>
</Form.Group>
<Form.Group className="mb-3" controlId="formSubject">
<Form.Label>Asunto:</Form.Label>
<Form.Control
type="text"
name="subject"
value={formData.subject}
onChange={handleChange}
required
/>
</Form.Group>
<Form.Group className="mb-3" controlId="formContent">
<Form.Label>Contenido:</Form.Label>
<Form.Control
as="textarea"
rows={5}
name="content"
value={formData.content}
onChange={handleChange}
required
/>
</Form.Group>
<Form.Group className="mb-3" controlId="formAttachments">
<Form.Label>Adjuntos:</Form.Label>
<Form.Control
type="file"
multiple
onChange={handleFileChange}
/>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Cancelar
</Button>
<Button variant="primary" onClick={handleSubmit}>
Enviar
</Button>
</Modal.Footer>
</Modal>
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import '../../css/MailList.css';
export default function MailList({ emails, onSelect, selectedEmail, className = '' }) {
return (
<div className={`mail-list ${className}`}>
{emails.map((mail, index) => (
<div
key={index}
className={`mail-item rounded-4 mb-2 ${selectedEmail?.index === index ? 'active' : ''}`}
onClick={() => onSelect(mail, index)}
>
<div className="subject">{mail.subject || "(Sin asunto)"}</div>
<div className="preview">
{!mail.content && "Sin contenido"}
{mail.content.includes("<") ? (
"Contenido HTML personalizado"
) : (
mail.content?.slice(0, 100)
)}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import '../../css/MailListMobile.css';
export default function MailListMobile({ emails, onSelect, selectedEmail, className = '' }) {
return (
<div className={`mail-list-mobile ${className}`}>
{emails.map((mail, index) => (
<div
key={index}
className={`mail-item-mobile rounded-4 mb-2 ${selectedEmail?.index === index ? 'active' : ''}`}
onClick={() => onSelect(mail, index)}
>
<div className="subject">{mail.subject || "(Sin asunto)"}</div>
<div className="preview">{mail.content?.slice(0, 100) || "Sin contenido"}</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import '../../css/MailToolbar.css';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faInbox, faPaperPlane, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
export default function MailToolbar({ onCompose }) {
return (
<div className="mail-toolbar-wrapper sticky-top">
<div className="mail-toolbar">
<div className="toolbar-icons">
<FontAwesomeIcon icon={faInbox} title="Entrada" className='text-success' />
<FontAwesomeIcon icon={faPaperPlane} title="Enviados" className='text-primary' />
<FontAwesomeIcon icon={faPen} title="Borradores" className='text-warning' />
<FontAwesomeIcon icon={faTrash} title="Spam" className='text-danger' />
</div>
<button className="toolbar-btn" onClick={onCompose}>
<FontAwesomeIcon icon={faPlus} />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import React, { useEffect, useRef } from 'react';
import '../../css/MailView.css';
import { faEnvelopeOpenText } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
export default function MailView({ email }) {
const mailContentRef = useRef(null);
const getFileIcon = (filename) => {
if (!filename) return '/images/icons/filetype/file_64.svg';
const extension = filename.split('.').pop()?.toLowerCase();
switch (extension) {
case 'pdf':
return '/images/icons/filetype/pdf_64.svg';
case 'jpg':
case 'jpeg':
return '/images/icons/filetype/jpg_64.svg';
case 'png':
return '/images/icons/filetype/png_64.svg';
case 'mp4':
case 'avi':
case 'mov':
return '/images/icons/filetype/mp4_64.svg';
case 'txt':
case 'text':
return '/images/icons/filetype/txt_64.svg';
default:
return '/images/icons/filetype/file_64.svg';
}
};
const processTextContent = (text) => {
if (!text) return "Sin contenido";
const urlRegex = /(https?:\/\/[^\s]+)/g;
const emailRegex = /([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)/g;
return text
.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(emailRegex, '<a href="mailto:$1">$1</a>')
.replace(/\n/g, '<br>');
};
const processHtmlContent = (html) => {
if (!html) return "Sin contenido";
let processedHtml = html;
processedHtml = processedHtml.replace(
/<a([^>]*?)(?:\s+target="[^"]*")?([^>]*?)>/g,
'<a$1 target="_blank" rel="noopener noreferrer"$2>'
);
processedHtml = processedHtml.replace(
/<img([^>]*?)>/g,
'<img$1 style="max-width: 100%; height: auto; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">'
);
return processedHtml;
};
useEffect(() => {
const currentRef = mailContentRef.current;
if (currentRef) {
const handleLinkClick = (e) => {
const link = e.target.closest('a');
if (link && link.href) {
e.preventDefault();
window.open(link.href, '_blank', 'noopener,noreferrer');
}
};
const images = currentRef.querySelectorAll('img');
images.forEach(img => {
img.style.maxWidth = '100%';
img.style.height = 'auto';
img.style.borderRadius = '4px';
img.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
if (!img.getAttribute('loading')) {
img.setAttribute('loading', 'lazy');
}
});
currentRef.addEventListener('click', handleLinkClick);
return () => {
if (currentRef) {
currentRef.removeEventListener('click', handleLinkClick);
}
};
}
}, [email]);
if (!email) {
return (
<div className='d-flex display-4 flex-column justify-content-center align-items-center vh-100 w-100'>
<FontAwesomeIcon icon={faEnvelopeOpenText} className="me-2" />
<h3 className='display-4'>No hay correo seleccionado</h3>
</div>
);
}
const from = email.from || "Remitente desconocido";
const date = new Date(email.date).toLocaleString();
const to = (email.to || []).join(', ');
const isHtml = email.content && (
email.content.includes('<html') ||
email.content.includes('<body') ||
email.content.includes('<div') ||
email.content.includes('<p>') ||
email.content.includes('<br') ||
email.content.includes('<img') ||
email.content.includes('<a href') ||
email.content.includes('<table') ||
email.content.includes('<ul') ||
email.content.includes('<ol') ||
/<[a-z][\s\S]*>/i.test(email.content)
);
return (
<div className="mail-view">
<div className="mail-header">
<h2>{email.subject || "(Sin asunto)"}</h2>
<div className="mail-meta">
<span><strong>De:</strong> {from}</span><br />
<span><strong>Para:</strong> {to}</span><br />
<span><strong>Fecha:</strong> {date}</span>
</div>
</div>
<div className="mail-body">
<div
ref={mailContentRef}
className="mail-content"
dangerouslySetInnerHTML={{
__html: isHtml
? processHtmlContent(email.content)
: processTextContent(email.content || "")
}}
/>
{email.attachments && email.attachments.length > 0 && (
<div className="mail-attachments mt-3">
<h5>Adjuntos:</h5>
<ul>
{email.attachments.map((a, i) => (
<li key={i}>
<a href={a.url} target="_blank" rel="noopener noreferrer">
<img
src={getFileIcon(a.name)}
alt="File icon"
width="16"
height="16"
style={{ marginRight: '8px' }}
/>
{a.name}
{a.size && <span className="attachment-size"> ({a.size})</span>}
</a>
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faInbox, faPaperPlane, faPenFancy, faTrash,
faPlus, faArrowLeft
} from '@fortawesome/free-solid-svg-icons';
import '../../css/MobileToolbar.css';
export default function MobileToolbar({ isViewingMail, onBack, onCompose, className }) {
return (
<div className={`search-toolbar-wrapper ${className}`}>
<div className="search-toolbar mobile-toolbar-content">
{!isViewingMail ? (
<>
<div className="toolbar-icons mobile-toolbar-icons">
<button className="icon-btn" title="Entrada">
<FontAwesomeIcon icon={faInbox} />
</button>
<button className="icon-btn" title="Enviados">
<FontAwesomeIcon icon={faPaperPlane} />
</button>
<button className="icon-btn" title="Borradores">
<FontAwesomeIcon icon={faPenFancy} />
</button>
<button className="icon-btn" title="Spam">
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
<div className="toolbar-buttons">
<button className="btn icon-btn" onClick={onCompose} title="Redactar">
<FontAwesomeIcon icon={faPlus} />
</button>
</div>
</>
) : (
<button className="btn icon-btn" onClick={onBack} title="Volver">
<FontAwesomeIcon icon={faArrowLeft} />
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import React, { useState } from 'react';
import '../../css/Sidebar.css';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEdit, faExclamationCircle, faInbox, faPaperPlane, faPen, faTrash } from '@fortawesome/free-solid-svg-icons';
import ComposeMailModal from './ComposeMailModal';
export default function Sidebar({ onFolderChange, onMailSend }) {
const [isComposeOpen, setIsComposeOpen] = useState(false);
const handleComposeOpen = () => setIsComposeOpen(true);
const handleComposeClose = () => setIsComposeOpen(false);
const handleSendMail = (mailData) => {
onMailSend(mailData);
};
return (
<div className="sidebar">
<button className="compose-btn" onClick={handleComposeOpen}>
<FontAwesomeIcon icon={faEdit} className="me-2" />
Redactar
</button>
<nav>
<a href="#" onClick={() => onFolderChange("INBOX")}>
<FontAwesomeIcon icon={faInbox} className="me-2" />
Bandeja de entrada
</a>
<a href="#" onClick={() => onFolderChange("Sent")}>
<FontAwesomeIcon icon={faPaperPlane} className="me-2" />
Enviados
</a>
<a className='disabled' href="#" onClick={() => onFolderChange("Drafts")}>
<FontAwesomeIcon icon={faPen} className="me-2" />
Borradores
</a>
<a className='disabled' href="#" onClick={() => onFolderChange("Spam")}>
<FontAwesomeIcon icon={faExclamationCircle} className="me-2" />
Spam
</a>
<a className='disabled' href="#" onClick={() => onFolderChange("Trash")}>
<FontAwesomeIcon icon={faTrash} className="me-2" />
Papelera
</a>
</nav>
<ComposeMailModal
isOpen={isComposeOpen}
onClose={handleComposeClose}
onSend={handleSendMail}
/>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import Slider from 'react-slick';
import '../css/CustomCarousel.css';
const CustomCarousel = ({ images }) => {
const settings = {
dots: false,
infinite: true,
speed: 500,
slidesToShow: 2,
slidesToScroll: 1,
arrows: false,
autoplay: true,
autoplaySpeed: 3000,
responsive: [
{
breakpoint: 768, // móviles
settings: {
slidesToShow: 1,
arrows: false,
autoplay: true,
autoplaySpeed: 3000,
dots: false,
infinite: true,
speed: 500
}
}
]
};
return (
<div className="my-4">
<Slider {...settings}>
{images.map((src, index) => (
<div key={index} className='carousel-img-wrapper'>
<img
src={src}
alt={`slide-${index}`}
className="carousel-img"
/>
</div>
))}
</Slider>
</div>
);
};
export default CustomCarousel;

View File

@@ -0,0 +1,15 @@
import PropTypes from 'prop-types';
const CustomContainer = ({ children }) => {
return (
<main className="px-4 py-5">
{children}
</main>
);
}
CustomContainer.propTypes = {
children: PropTypes.node.isRequired,
}
export default CustomContainer;

View File

@@ -0,0 +1,26 @@
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Modal, Button } from "react-bootstrap";
const CustomModal = ({ show, onClose, title, children }) => {
return (
<Modal show={show} onHide={onClose} size="xl" centered>
<Modal.Header className='justify-content-between rounded-top-4'>
<Modal.Title>{title}</Modal.Title>
<Button variant='transparent' onClick={onClose}>
<FontAwesomeIcon icon={faXmark} className='close-button fa-xl' />
</Button>
</Modal.Header>
<Modal.Body className="rounded-bottom-4 p-0"
style={{
maxHeight: '80vh',
overflowY: 'auto',
padding: '1rem',
}}>
{children}
</Modal.Body>
</Modal>
);
}
export default CustomModal;

View File

@@ -0,0 +1,60 @@
import { faTrashAlt } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Card, Button, OverlayTrigger, Tooltip } from "react-bootstrap";
import '../../css/File.css';
const File = ({ file, onDelete }) => {
const getIcon = (type) => {
const dir = "/images/icons/filetype/";
switch (type) {
case "image/jpeg":
return dir + "jpg_64.svg";
case "image/png":
return dir + "png_64.svg";
case "video/mp4":
return dir + "mp4_64.svg";
case "application/pdf":
return dir + "pdf_64.svg";
case "text/plain":
return dir + "txt_64.svg";
default:
return dir + "file_64.svg";
}
};
return (
<Card
className="file-card col-sm-3 col-lg-2 col-xxl-1 m-0 p-0 position-relative text-decoration-none bg-transparent"
onClick={() => window.open(`https://miarma.net/files/huertos/${file.file_name}`, "_blank")}
>
<Card.Body className="text-center">
<img
src={getIcon(file.mime_type)}
alt={file.file_name}
className="img-fluid mb-2"
/>
<OverlayTrigger
placement="bottom"
overlay={<Tooltip>{file.file_name}</Tooltip>}
>
<p className="m-0 p-0 text-truncate">{file.file_name}</p>
</OverlayTrigger>
</Card.Body>
<Button
variant="transparent"
size="md"
color="text-danger"
className="delete-btn position-absolute top-0 end-0 m-0"
onClick={(e) => {
e.stopPropagation();
onDelete?.(file);
}}
>
<FontAwesomeIcon icon={faTrashAlt} />
</Button>
</Card>
);
};
export default File;

View File

@@ -0,0 +1,106 @@
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { Card, CloseButton } from "react-bootstrap";
import "../../css/FileUpload.css";
const MAX_FILE_SIZE_MB = 10;
const FileUpload = forwardRef(({ onFilesSelected }, ref) => {
const fileInputRef = useRef();
const [highlight, setHighlight] = useState(false);
const [selectedFiles, setSelectedFiles] = useState([]);
useImperativeHandle(ref, () => ({
resetSelectedFiles: () => {
setSelectedFiles([]);
if (fileInputRef.current) {
fileInputRef.current.value = null; // limpia input real
}
},
}));
const handleFiles = (files) => {
const validFiles = Array.from(files).filter(
(file) => file.size <= MAX_FILE_SIZE_MB * 1024 * 1024
);
setSelectedFiles(validFiles);
if (onFilesSelected) onFilesSelected(validFiles);
};
const handleInputChange = (e) => {
handleFiles(e.target.files);
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setHighlight(false);
handleFiles(e.dataTransfer.files);
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
setHighlight(true);
};
const handleDragLeave = () => {
setHighlight(false);
};
const openFileDialog = () => {
fileInputRef.current?.click();
};
const removeFile = (index) => {
const updated = [...selectedFiles];
updated.splice(index, 1);
setSelectedFiles(updated);
if (onFilesSelected) onFilesSelected(updated);
};
return (
<Card
className={`upload-card shadow-sm mb-4 ${highlight ? "highlight" : ""}`}
onClick={openFileDialog}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
role="button"
>
<Card.Body className="text-center">
<h2 className="mb-3">📎 Subir archivo</h2>
<p>
Arrastra o haz click para seleccionar archivos (Máx. 10MB)
</p>
<input
ref={fileInputRef}
type="file"
accept=".pdf"
multiple
className="d-none"
onChange={handleInputChange}
/>
{selectedFiles.length > 0 && (
<ul className="file-list text-start mt-4 px-3">
{selectedFiles.map((file, idx) => (
<li
key={idx}
className="d-flex justify-content-between align-items-center mb-2"
>
<span>📄 {file.name}</span>
<CloseButton
onClick={(e) => {
e.stopPropagation();
removeFile(idx);
}}
/>
</li>
))}
</ul>
)}
</Card.Body>
</Card>
);
});
export default FileUpload;

55
src/components/Footer.jsx Normal file
View File

@@ -0,0 +1,55 @@
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLocationDot, faEnvelope } from '@fortawesome/free-solid-svg-icons';
import '../css/Footer.css';
const Footer = () => {
const [heart, setHeart] = useState('💜');
useEffect(() => {
const hearts = ["❤️", "💛", "🧡", "💚", "💙", "💜"];
const randomHeart = () => hearts[Math.floor(Math.random() * hearts.length)];
const interval = setInterval(() => {
setHeart(randomHeart());
}, 3000);
return () => clearInterval(interval);
}, []);
return (
<footer className="footer d-flex flex-column align-items-center gap-5 pt-5 px-4">
<div className="footer-columns w-100" style={{ maxWidth: '900px' }}>
<div className="footer-column">
<h4 className="footer-title">Datos de Contacto</h4>
<div className="contact-info p-4">
<a
href="https://www.google.com/maps?q=Calle+Cronos+S/N,+Bellavista,+Sevilla,+41014"
target="_blank"
className='text-break d-block'
rel="noopener noreferrer"
>
<FontAwesomeIcon icon={faLocationDot} className="fa-icon me-2 " />
Calle Cronos S/N, Bellavista, Sevilla, 41014
</a>
<a href="mailto:huertoslasaludbellavista@gmail.com" className="text-break d-block">
<FontAwesomeIcon icon={faEnvelope} className="fa-icon me-2" />
huertoslasaludbellavista@gmail.com
</a>
</div>
</div>
</div>
<div className="footer-bottom w-100 py-5 text-center">
<h6 id="devd" className='m-0'>
Hecho con <span className="heart-anim">{heart}</span> por{' '}
<a href="https://gallardo.dev" target="_blank" rel="noopener noreferrer">
Gallardo7761
</a>
</h6>
</div>
</footer>
);
};
export default Footer;

View File

@@ -0,0 +1,196 @@
import { useEffect, useState } from 'react';
import {
Card, Badge, Button, Form
} from 'react-bootstrap';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faMoneyBillWave,
faTruck,
faReceipt,
faTrash,
faEdit,
faTimes,
faEllipsisVertical
} from '@fortawesome/free-solid-svg-icons';
import { motion as _motion } from 'framer-motion';
import AnimatedDropdown from '../../components/AnimatedDropdown';
import { useTheme } from '../../hooks/useTheme';
import '../../css/IngresoCard.css';
import { CONSTANTS } from '../../util/constants';
import { DateParser } from '../../util/parsers/dateParser';
import { renderErrorAlert } from '../../util/alertHelpers';
import { getNowAsLocalDatetime } from '../../util/date';
import SpanishDateTimePicker from '../SpanishDateTimePicker';
const MotionCard = _motion.create(Card);
const getTypeLabel = (type) => type === CONSTANTS.PAYMENT_TYPE_BANK ? "Banco" : "Caja";
const getTypeColor = (type, theme) => type === 0 ? "primary" : theme === "light" ? "dark" : "light";
const getTypeTextColor = (type, theme) => type === 0 ? "light" : theme === "light" ? "light" : "dark";
const getPFP = (tipo) => {
const base = '/images/icons/';
const map = {
1: 'cash.svg',
0: 'bank.svg'
};
return base + (map[tipo] || 'farmer.svg');
};
const GastoCard = ({ gasto, isNew = false, onCreate, onUpdate, onDelete, onCancel, error, onClearError }) => {
const createMode = isNew;
const [editMode, setEditMode] = useState(createMode);
const { theme } = useTheme();
const [formData, setFormData] = useState({
concept: gasto.concept || '',
amount: gasto.amount || 0,
supplier: gasto.supplier || '',
invoice: gasto.invoice || '',
type: gasto.type ?? 0,
created_at: gasto.created_at?.slice(0, 16) || (isNew ? getNowAsLocalDatetime() : ''),
});
useEffect(() => {
if (!editMode) {
setFormData({
concept: gasto.concept || '',
amount: gasto.amount || 0,
supplier: gasto.supplier || '',
invoice: gasto.invoice || '',
type: gasto.type ?? 0,
created_at: gasto.created_at?.slice(0, 16) || (isNew ? getNowAsLocalDatetime() : ''),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gasto, editMode]);
const handleChange = (field, value) => setFormData(prev => ({ ...prev, [field]: value }));
const handleDelete = () => typeof onDelete === 'function' && onDelete(gasto.expense_id);
const handleCancel = () => {
if (onClearError) onClearError();
if (isNew && typeof onCancel === 'function') return onCancel();
setEditMode(false);
};
const handleSave = () => {
if (onClearError) onClearError();
const newExpense = { ...gasto, ...formData };
if (createMode && typeof onCreate === 'function') return onCreate(newExpense);
if (typeof onUpdate === 'function') return onUpdate(newExpense, gasto.expense_id);
};
return (
<MotionCard className="ingreso-card shadow-sm rounded-4 border-0 h-100">
<Card.Header className="d-flex justify-content-between align-items-center rounded-top-4 bg-light-green">
<div className="d-flex align-items-center">
<img src={getPFP(formData.type)} width={36} alt="Tipo de gasto" className='me-3' />
<div className="d-flex flex-column">
<span className="fw-bold">
{editMode ? (
<Form.Control
className="themed-input"
size="sm"
value={formData.concept}
onChange={(e) => handleChange('concept', e.target.value.toUpperCase())}
/>
) : formData.concept}
</span>
<small>
{editMode ? (
<SpanishDateTimePicker
selected={new Date(formData.created_at)}
onChange={(date) =>
handleChange('created_at', date.toISOString().slice(0, 16))
}
/>
) : (
DateParser.isoToStringWithTime(formData.created_at)
)}
</small>
</div>
</div>
{!createMode && !editMode && (
<AnimatedDropdown className='end-0' icon={<FontAwesomeIcon icon={faEllipsisVertical} className="fa-xl text-dark" />}>
{({ closeDropdown }) => (
<>
<div className="dropdown-item d-flex align-items-center" onClick={() => { setEditMode(true); closeDropdown(); }}>
<FontAwesomeIcon icon={faEdit} className="me-2" />Editar
</div>
<div className="dropdown-item d-flex align-items-center text-danger" onClick={() => { handleDelete(); closeDropdown(); }}>
<FontAwesomeIcon icon={faTrash} className="me-2" />Eliminar
</div>
</>
)}
</AnimatedDropdown>
)}
</Card.Header>
<Card.Body>
{(editMode || createMode) && renderErrorAlert(error)}
<Card.Text className="mb-2">
<FontAwesomeIcon icon={faMoneyBillWave} className="me-2" />
<strong>Importe:</strong>{' '}
{editMode ? (
<Form.Control className="themed-input" size="sm" type="number" step="0.01" value={formData.amount} onChange={(e) => handleChange('amount', parseFloat(e.target.value))} style={{ maxWidth: '150px', display: 'inline-block' }} />
) : `${formData.amount.toFixed(2)}`}
</Card.Text>
<Card.Text className="mb-2">
<FontAwesomeIcon icon={faTruck} className="me-2" />
<strong>Proveedor:</strong>{' '}
{editMode ? (
<Form.Control className="themed-input" size="sm" type="text" value={formData.supplier} onChange={(e) => handleChange('supplier', e.target.value)} />
) : formData.supplier}
</Card.Text>
<Card.Text className="mb-2">
<FontAwesomeIcon icon={faReceipt} className="me-2" />
<strong>Factura:</strong>{' '}
{editMode ? (
<Form.Control className="themed-input" size="sm" type="text" value={formData.invoice} onChange={(e) => handleChange('invoice', e.target.value)} />
) : formData.invoice}
</Card.Text>
{editMode ? (
<>
<Form.Group className="mb-3">
<Form.Label>Tipo de gasto</Form.Label>
<Form.Select className='themed-input' size="sm" value={formData.type} onChange={(e) => handleChange('type', parseInt(e.target.value))}>
<option value={0}>Banco</option>
<option value={1}>Caja</option>
</Form.Select>
</Form.Group>
<div className="d-flex justify-content-end gap-2">
<Button variant="secondary" size="sm" onClick={handleCancel}><FontAwesomeIcon icon={faTimes} /> Cancelar</Button>
<Button variant="primary" size="sm" onClick={handleSave}>Guardar</Button>
</div>
</>
) : (
<div className="text-end">
<Badge bg={getTypeColor(formData.type, theme)} text={getTypeTextColor(formData.type, theme)}>
{getTypeLabel(formData.type)}
</Badge>
</div>
)}
</Card.Body>
</MotionCard>
);
};
GastoCard.propTypes = {
gasto: PropTypes.object.isRequired,
isNew: PropTypes.bool,
onCreate: PropTypes.func,
onUpdate: PropTypes.func,
onDelete: PropTypes.func,
onCancel: PropTypes.func
};
export default GastoCard;

View File

@@ -0,0 +1,68 @@
import PropTypes from 'prop-types';
const GastosFilter = ({ filters, onChange }) => {
const handleCheckboxChange = (key) => {
if (key === 'todos') {
const newValue = !filters.todos;
onChange({
todos: newValue,
banco: newValue,
caja: newValue
});
} else {
const updated = { ...filters, [key]: !filters[key] };
const allTrue = Object.entries(updated)
.filter(([k]) => k !== 'todos')
.every(([, v]) => v === true);
updated.todos = allTrue;
onChange(updated);
}
};
return (
<>
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="todosCheck"
className="me-2"
checked={filters.todos}
onChange={() => handleCheckboxChange('todos')}
/>
<label htmlFor="todosCheck" className="m-0">Mostrar Todos</label>
</div>
<hr className="dropdown-divider" />
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="bancoCheck"
className="me-2"
checked={filters.banco}
onChange={() => handleCheckboxChange('banco')}
/>
<label htmlFor="bancoCheck" className="m-0">Banco</label>
</div>
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="cajaCheck"
className="me-2"
checked={filters.caja}
onChange={() => handleCheckboxChange('caja')}
/>
<label htmlFor="cajaCheck" className="m-0">Caja</label>
</div>
</>
);
};
GastosFilter.propTypes = {
filters: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired
};
export default GastosFilter;

View File

@@ -0,0 +1,117 @@
import { Document, Page, Text, View, StyleSheet, Font, Image } from '@react-pdf/renderer';
import { CONSTANTS } from '../../util/constants';
Font.register({
family: 'Open Sans',
fonts: [{ src: '/fonts/OpenSans.ttf', fontWeight: 'normal' }]
});
const styles = StyleSheet.create({
page: {
padding: 25,
fontSize: 12,
fontFamily: 'Open Sans',
backgroundColor: '#F8F9FA',
},
headerContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 25,
justifyContent: 'left',
},
headerText: {
flexDirection: 'column',
marginLeft: 25,
},
logo: {
width: 60,
height: 60,
},
header: {
fontSize: 26,
fontWeight: 'bold',
color: '#2C3E50',
},
subHeader: {
fontSize: 12,
marginTop: 5,
color: '#34495E'
},
tableHeader: {
flexDirection: 'row',
backgroundColor: '#3E8F5A',
fontWeight: 'bold',
paddingVertical: 6,
paddingHorizontal: 5,
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
},
headerCell: {
paddingHorizontal: 5,
color: '#ffffff',
fontWeight: 'bold',
fontSize: 10,
},
row: {
flexDirection: 'row',
paddingVertical: 5,
paddingHorizontal: 5,
borderBottomWidth: 1,
borderBottomColor: '#D5D8DC'
},
cell: {
paddingHorizontal: 5,
fontSize: 9,
color: '#2C3E50'
}
});
const parseDate = (iso) => {
if (!iso) return '';
const [y, m, d] = iso.split('T')[0].split('-');
return `${d}/${m}/${y}`;
};
const getTypeLabel = (type) => type === CONSTANTS.PAYMENT_TYPE_BANK ? 'Banco' : 'Caja';
export const GastosPDF = ({ gastos }) => (
<Document>
<Page size="A4" orientation="landscape" style={styles.page}>
<View style={styles.headerContainer}>
<Image src="/images/logo.png" style={styles.logo} />
<View style={styles.headerText}>
<Text style={styles.header}>Listado de Gastos</Text>
<Text style={styles.subHeader}>Asociación Huertos La Salud - Bellavista Generado el {new Date().toLocaleDateString()} a las {new Date().toLocaleTimeString()}</Text>
</View>
</View>
<View style={styles.tableHeader}>
<Text style={[styles.headerCell, { flex: 2 }]}>Fecha</Text>
<Text style={[styles.headerCell, { flex: 4 }]}>Concepto</Text>
<Text style={[styles.headerCell, { flex: 2 }]}>Importe</Text>
<Text style={[styles.headerCell, { flex: 3 }]}>Proveedor</Text>
<Text style={[styles.headerCell, { flex: 2 }]}>Factura</Text>
<Text style={[styles.headerCell, { flex: 1 }]}>Tipo</Text>
</View>
{gastos.map((gasto, idx) => (
<View
key={idx}
style={[
styles.row,
{ backgroundColor: idx % 2 === 0 ? '#ECF0F1' : '#FDFEFE' },
{ borderBottomLeftRadius: idx === gastos.length - 1 ? 10 : 0 },
{ borderBottomRightRadius: idx === gastos.length - 1 ? 10 : 0 },
]}
>
<Text style={[styles.cell, { flex: 2 }]}>{parseDate(gasto.created_at)}</Text>
<Text style={[styles.cell, { flex: 4 }]}>{gasto.concept}</Text>
<Text style={[styles.cell, { flex: 2 }]}>{gasto.amount.toFixed(2)} </Text>
<Text style={[styles.cell, { flex: 3 }]}>{gasto.supplier}</Text>
<Text style={[styles.cell, { flex: 2 }]}>{gasto.invoice}</Text>
<Text style={[styles.cell, { flex: 1 }]}>{getTypeLabel(gasto.type)}</Text>
</View>
))}
</Page>
</Document>
);

19
src/components/Header.jsx Normal file
View File

@@ -0,0 +1,19 @@
import '../css/Header.css';
import { Link } from 'react-router-dom';
const Header = () => {
return (
<header className={`text-center bg-img`}>
<div className="m-0 p-5 mask">
<div className="d-flex flex-column justify-content-center align-items-center h-100">
<Link to='/' className='text-decoration-none'>
<h1 className='header-title m-0 text-white shadowed'>Asociación Huertos La Salud - Bellavista</h1>
</Link>
</div>
</div>
</header>
);
}
export default Header;

View File

@@ -0,0 +1,283 @@
import { useEffect, useState } from 'react';
import {
Card, Badge, Button, Form, OverlayTrigger, Tooltip
} from 'react-bootstrap';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faUser,
faMoneyBillWave,
faTrash,
faEdit,
faTimes,
faEllipsisVertical
} from '@fortawesome/free-solid-svg-icons';
import { motion as _motion } from 'framer-motion';
import AnimatedDropdown from '../../components/AnimatedDropdown';
import { CONSTANTS } from '../../util/constants';
import '../../css/IngresoCard.css';
import { useTheme } from '../../hooks/useTheme';
import { DateParser } from '../../util/parsers/dateParser';
import { renderErrorAlert } from '../../util/alertHelpers';
import { getNowAsLocalDatetime } from '../../util/date';
import SpanishDateTimePicker from '../SpanishDateTimePicker';
const MotionCard = _motion.create(Card);
const getTypeLabel = (type) => type === CONSTANTS.PAYMENT_TYPE_BANK ? "Banco" : "Caja";
const getFrequencyLabel = (freq) => freq === CONSTANTS.PAYMENT_FREQUENCY_BIYEARLY ? "Semestral" : "Anual";
const getTypeColor = (type, theme) => type === CONSTANTS.PAYMENT_TYPE_BANK ? "primary" : theme === "light" ? "dark" : "light";
const getTypeTextColor = (type, theme) => type === CONSTANTS.PAYMENT_TYPE_BANK ? "light" : theme === "light" ? "light" : "dark";
const getFreqColor = (freq) => freq === CONSTANTS.PAYMENT_FREQUENCY_BIYEARLY ? "warning" : "danger";
const getFreqTextColor = (freq) => freq === CONSTANTS.PAYMENT_FREQUENCY_BIYEARLY ? "dark" : "light";
const getPFP = (tipo) => {
const base = '/images/icons/';
const map = {
1: 'cash.svg',
0: 'bank.svg'
};
return base + (map[tipo] || 'farmer.svg');
};
const IngresoCard = ({
income,
isNew = false,
onCreate,
onUpdate,
onDelete,
onCancel,
className = '',
editable = true,
error,
onClearError,
members = []
}) => {
const createMode = isNew;
const [editMode, setEditMode] = useState(createMode);
const { theme } = useTheme();
const [formData, setFormData] = useState({
concept: income.concept || '',
amount: income.amount || 0,
type: income.type ?? CONSTANTS.PAYMENT_TYPE_CASH,
frequency: income.frequency ?? CONSTANTS.PAYMENT_FREQUENCY_YEARLY,
member_number: income.member_number,
created_at: income.created_at?.slice(0, 16) || (isNew ? getNowAsLocalDatetime() : ''),
});
useEffect(() => {
if (!editMode) {
setFormData({
concept: income.concept || '',
amount: income.amount || 0,
type: income.type ?? CONSTANTS.PAYMENT_TYPE_CASH,
frequency: income.frequency ?? CONSTANTS.PAYMENT_FREQUENCY_YEARLY,
display_name: income.display_name,
member_number: income.member_number,
created_at: income.created_at?.slice(0, 16) || (isNew ? getNowAsLocalDatetime() : ''),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [income, editMode]);
const handleChange = (field, value) =>
setFormData(prev => ({ ...prev, [field]: value }));
const handleCancel = () => {
if (onClearError) onClearError();
if (isNew && typeof onCancel === 'function') return onCancel();
setEditMode(false);
};
const handleSave = () => {
if (onClearError) onClearError();
const newIncome = { ...income, ...formData };
if (createMode && typeof onCreate === 'function') return onCreate(newIncome);
if (typeof onUpdate === 'function') return onUpdate(newIncome, income.income_id);
};
const handleDelete = () => typeof onDelete === 'function' && onDelete(income.income_id);
const uniqueMembers = Array.from(
new Map(members.map(item => [item.member_number, item])).values()
).sort((a, b) => a.member_number - b.member_number);
return (
<MotionCard className={`ingreso-card shadow-sm rounded-4 border-0 h-100 ${className}`}>
<Card.Header className="rounded-top-4 bg-light-green">
<div className="d-flex justify-content-between align-items-center w-100">
<div className="d-flex align-items-center">
<img src={getPFP(formData.type)} width={36} alt="Ingreso" className='me-3' />
<div className="d-flex flex-column">
<span className="fw-bold">
{editMode ? (
<Form.Control
className="themed-input"
size="sm"
value={formData.concept}
onChange={(e) => handleChange('concept', e.target.value.toUpperCase())}
/>
) : formData.concept}
</span>
<small>
{editMode ? (
<SpanishDateTimePicker
selected={new Date(formData.created_at)}
onChange={(date) =>
handleChange('created_at', date.toISOString().slice(0, 16))
}
/>
) : (
DateParser.isoToStringWithTime(formData.created_at)
)}
</small>
</div>
</div>
{editable && !createMode && !editMode && (
<AnimatedDropdown
className='ms-3'
icon={<FontAwesomeIcon icon={faEllipsisVertical} className="fa-xl" />}
>
{({ closeDropdown }) => (
<>
<div className="dropdown-item d-flex align-items-center" onClick={() => { setEditMode(true); onClearError && onClearError(); closeDropdown(); }}>
<FontAwesomeIcon icon={faEdit} className="me-2" />Editar
</div>
<div className="dropdown-item d-flex align-items-center text-danger" onClick={() => { handleDelete(); closeDropdown(); }}>
<FontAwesomeIcon icon={faTrash} className="me-2" />Eliminar
</div>
</>
)}
</AnimatedDropdown>
)}
</div>
</Card.Header>
<Card.Body>
{(editMode || createMode) && renderErrorAlert(error)}
<Card.Text className="mb-2">
<FontAwesomeIcon icon={faUser} className="me-2" />
<strong>Socio:</strong>{' '}
{createMode ? (
<Form.Select
className="themed-input"
size="sm"
value={formData.member_number}
onChange={(e) => handleChange('member_number', parseInt(e.target.value))}
style={{ maxWidth: '300px', display: 'inline-block' }}
>
{uniqueMembers.map((m) => (
<option key={m.member_number} value={m.member_number}>
{`${m.display_name} (${m.member_number})`}
</option>
))}
</Form.Select>
) : editMode ? (
<OverlayTrigger
placement="top"
overlay={<Tooltip>Este campo no se puede editar. Para cambiar el socio, elimina y vuelve a crear el ingreso.</Tooltip>}
>
<Form.Control
className="themed-input"
disabled
size="sm"
type="text"
value={`${formData.display_name || 'Socio'} (${formData.member_number})`}
style={{ maxWidth: '300px', display: 'inline-block' }}
/>
</OverlayTrigger>
) : (
formData.display_name ? (
<>
<OverlayTrigger
placement="top"
overlay={<Tooltip>{formData.display_name}</Tooltip>}
>
<span className="text-truncate d-inline-block" style={{ maxWidth: '200px', verticalAlign: 'middle' }}>
{formData.display_name}
</span>
</OverlayTrigger>
&nbsp;({formData.member_number})
</>
) : formData.member_number
)}
</Card.Text>
<Card.Text className="mb-2">
<FontAwesomeIcon icon={faMoneyBillWave} className="me-2" />
<strong>Importe:</strong>{' '}
{editMode ? (
<Form.Control
className="themed-input"
size="sm"
type="number"
step="0.01"
value={formData.amount}
onChange={(e) => handleChange('amount', parseFloat(e.target.value))}
style={{ maxWidth: '150px', display: 'inline-block' }}
/>
) : `${formData.amount.toFixed(2)}`}
</Card.Text>
{editMode ? (
<>
<Form.Group className="mb-2">
<Form.Label>Tipo de pago</Form.Label>
<Form.Select
className='themed-input'
size="sm"
value={formData.type}
onChange={(e) => handleChange('type', parseInt(e.target.value))}
>
<option value={CONSTANTS.PAYMENT_TYPE_CASH}>Caja</option>
<option value={CONSTANTS.PAYMENT_TYPE_BANK}>Banco</option>
</Form.Select>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Frecuencia</Form.Label>
<Form.Select
className='themed-input'
size="sm"
value={formData.frequency}
onChange={(e) => handleChange('frequency', parseInt(e.target.value))}
>
<option value={CONSTANTS.PAYMENT_FREQUENCY_YEARLY}>Anual</option>
<option value={CONSTANTS.PAYMENT_FREQUENCY_BIYEARLY}>Semestral</option>
</Form.Select>
</Form.Group>
<div className="d-flex justify-content-end gap-2">
<Button variant="secondary" size="sm" onClick={handleCancel}><FontAwesomeIcon icon={faTimes} /> Cancelar</Button>
<Button variant="primary" size="sm" onClick={handleSave}>Guardar</Button>
</div>
</>
) : (
<div className="text-end">
<Badge bg={getTypeColor(formData.type, theme)} text={getTypeTextColor(formData.type, theme)} className="me-1">{getTypeLabel(formData.type)}</Badge>
<Badge bg={getFreqColor(formData.frequency)} text={getFreqTextColor(formData.frequency)}>{getFrequencyLabel(formData.frequency)}</Badge>
</div>
)}
</Card.Body>
</MotionCard>
);
};
IngresoCard.propTypes = {
income: PropTypes.object.isRequired,
isNew: PropTypes.bool,
onCreate: PropTypes.func,
onUpdate: PropTypes.func,
onDelete: PropTypes.func,
onCancel: PropTypes.func,
className: PropTypes.string,
editable: PropTypes.bool,
error: PropTypes.string,
onClearError: PropTypes.func,
members: PropTypes.array
};
export default IngresoCard;

View File

@@ -0,0 +1,92 @@
import PropTypes from 'prop-types';
const IngresosFilter = ({ filters, onChange }) => {
const handleCheckboxChange = (key) => {
if (key === 'todos') {
const newValue = !filters.todos;
onChange({
todos: newValue,
banco: newValue,
caja: newValue,
semestral: newValue,
anual: newValue
});
} else {
const updated = { ...filters, [key]: !filters[key] };
const allTrue = Object.entries(updated)
.filter(([k]) => k !== 'todos')
.every(([, v]) => v === true);
updated.todos = allTrue;
onChange(updated);
}
};
return (
<>
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="todosCheck"
className="me-2"
checked={filters.todos}
onChange={() => handleCheckboxChange('todos')}
/>
<label htmlFor="todosCheck" className="m-0">Mostrar Todos</label>
</div>
<hr className="dropdown-divider" />
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="bancoCheck"
className="me-2"
checked={filters.banco}
onChange={() => handleCheckboxChange('banco')}
/>
<label htmlFor="bancoCheck" className="m-0">Banco</label>
</div>
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="cajaCheck"
className="me-2"
checked={filters.caja}
onChange={() => handleCheckboxChange('caja')}
/>
<label htmlFor="cajaCheck" className="m-0">Caja</label>
</div>
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="semestralCheck"
className="me-2"
checked={filters.semestral}
onChange={() => handleCheckboxChange('semestral')}
/>
<label htmlFor="semestralCheck" className="m-0">Semestral</label>
</div>
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="anualCheck"
className="me-2"
checked={filters.anual}
onChange={() => handleCheckboxChange('anual')}
/>
<label htmlFor="anualCheck" className="m-0">Anual</label>
</div>
</>
);
};
IngresosFilter.propTypes = {
filters: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired
};
export default IngresosFilter;

View File

@@ -0,0 +1,118 @@
import { Document, Page, Text, View, StyleSheet, Font, Image } from '@react-pdf/renderer';
import { CONSTANTS } from '../../util/constants';
Font.register({
family: 'Open Sans',
fonts: [{ src: '/fonts/OpenSans.ttf', fontWeight: 'normal' }]
});
const styles = StyleSheet.create({
page: {
padding: 25,
fontSize: 12,
fontFamily: 'Open Sans',
backgroundColor: '#F8F9FA',
},
headerContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 25,
justifyContent: 'left',
},
headerText: {
flexDirection: 'column',
marginLeft: 25,
},
logo: {
width: 60,
height: 60,
},
header: {
fontSize: 26,
fontWeight: 'bold',
color: '#2C3E50',
},
subHeader: {
fontSize: 12,
marginTop: 5,
color: '#34495E'
},
tableHeader: {
flexDirection: 'row',
backgroundColor: '#3E8F5A',
fontWeight: 'bold',
paddingVertical: 6,
paddingHorizontal: 5,
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
},
headerCell: {
paddingHorizontal: 5,
color: '#ffffff',
fontWeight: 'bold',
fontSize: 10,
},
row: {
flexDirection: 'row',
paddingVertical: 5,
paddingHorizontal: 5,
borderBottomWidth: 1,
borderBottomColor: '#D5D8DC'
},
cell: {
paddingHorizontal: 5,
fontSize: 9,
color: '#2C3E50'
}
});
const parseDate = (iso) => {
if (!iso) return '';
const [y, m, d] = iso.split('T')[0].split('-');
return `${d}/${m}/${y}`;
};
const getTypeLabel = (type) => type === CONSTANTS.PAYMENT_TYPE_BANK ? 'Banco' : 'Caja';
const getFreqLabel = (freq) => freq === CONSTANTS.PAYMENT_FREQUENCY_BIYEARLY ? 'Semestral' : 'Anual';
export const IngresosPDF = ({ ingresos }) => (
<Document>
<Page size="A4" orientation="landscape" style={styles.page}>
<View style={styles.headerContainer}>
<Image src="/images/logo.png" style={styles.logo} />
<View style={styles.headerText}>
<Text style={styles.header}>Listado de ingresos</Text>
<Text style={styles.subHeader}>Asociación Huertos La Salud - Bellavista Generado el {new Date().toLocaleDateString()} a las {new Date().toLocaleTimeString()}</Text>
</View>
</View>
<View style={styles.tableHeader}>
<Text style={[styles.headerCell, { flex: 1 }]}>Socio </Text>
<Text style={[styles.headerCell, { flex: 4 }]}>Concepto</Text>
<Text style={[styles.headerCell, { flex: 1 }]}>Importe</Text>
<Text style={[styles.headerCell, { flex: 1 }]}>Tipo</Text>
<Text style={[styles.headerCell, { flex: 1 }]}>Frecuencia</Text>
<Text style={[styles.headerCell, { flex: 2 }]}>Fecha</Text>
</View>
{ingresos.map((ing, idx) => (
<View
key={idx}
style={[
styles.row,
{ backgroundColor: idx % 2 === 0 ? '#ECF0F1' : '#FDFEFE' },
{ borderBottomLeftRadius: idx === ingresos.length - 1 ? 10 : 0 },
{ borderBottomRightRadius: idx === ingresos.length - 1 ? 10 : 0 },
]}
>
<Text style={[styles.cell, { flex: 1 }]}>{ing.member_number}</Text>
<Text style={[styles.cell, { flex: 3 }]}>{ing.concept}</Text>
<Text style={[styles.cell, { flex: 1 }]}>{ing.amount.toFixed(2)} </Text>
<Text style={[styles.cell, { flex: 1 }]}>{getTypeLabel(ing.type)}</Text>
<Text style={[styles.cell, { flex: 1 }]}>{getFreqLabel(ing.frequency)}</Text>
<Text style={[styles.cell, { flex: 2 }]}>{parseDate(ing.created_at)}</Text>
</View>
))}
</Page>
</Document>
);

15
src/components/List.jsx Normal file
View File

@@ -0,0 +1,15 @@
import ListItem from "./ListItem";
import {ListGroup} from 'react-bootstrap';
import '../css/List.css';
const List = ({ datos, config }) => {
return (
<ListGroup className="gap-2">
{datos.map((item, index) => (
<ListItem key={index} item={item} config={config} index={index} />
))}
</ListGroup>
);
};
export default List;

View File

@@ -0,0 +1,57 @@
import { motion as _motion } from "framer-motion";
import { ListGroup } from "react-bootstrap";
import '../css/ListItem.css';
const MotionListGroupItem = _motion.create(ListGroup.Item);
const ListItem = ({ item, config, index }) => {
const {
title,
subtitle,
numericField,
pfp,
showIndex,
} = config;
return (
<MotionListGroupItem
className="custom-list-item d-flex justify-content-between rounded-4 align-items-center"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="d-flex align-items-center gap-3">
{showIndex && (
<div className="list-item-index">
{index + 1}
</div>
)}
{pfp && item[pfp] && (
<img
src={item[pfp]}
alt="pfp"
className="list-item-avatar"
/>
)}
<div className="d-flex flex-column">
{title && item[title] && (
<h5 className="fw-bold m-0">{item[title]}</h5>
)}
{subtitle && item[subtitle] && (
<div className="subtitle m-0">{item[subtitle]}</div>
)}
</div>
</div>
{numericField && item[numericField] !== undefined && (
<span className="badge bg-primary rounded-pill">
{item[numericField]}
</span>
)}
</MotionListGroupItem>
);
};
export default ListItem;

View File

@@ -0,0 +1,10 @@
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
const LoadingIcon = () => {
return (
<FontAwesomeIcon icon={faSpinner} className='fa-spin fa-lg' />
);
}
export default LoadingIcon;

57
src/components/Mapa3D.jsx Normal file
View File

@@ -0,0 +1,57 @@
// Mapa3D.jsx
import { useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
export default function Mapa3D() {
const mapRef = useRef(null);
useEffect(() => {
const map = new maplibregl.Map({
container: mapRef.current,
style: {
version: 8,
sources: {
satellite: {
type: 'raster',
tiles: [
'https://api.maptiler.com/maps/satellite/{z}/{x}/{y}@2x.jpg?key=Ie0BAF3X6PIp1aV260ar'
],
tileSize: 512,
attribution: '&copy; <a href="https://www.maptiler.com/">MapTiler</a>'
}
},
layers: [
{
id: 'satellite',
type: 'raster',
source: 'satellite',
minzoom: 0,
maxzoom: 22
}
]
},
center: [-5.9648, 37.3282],
zoom: 17,
pitch: 30,
bearing: -10,
antialias: true,
scrollZoom: false
});
map.addControl(new maplibregl.NavigationControl());
new maplibregl.Marker()
.setLngLat([-5.9648, 37.3282])
.addTo(map);
return () => map.remove();
}, []);
return (
<div
ref={mapRef}
style={{ width: '100%', height: '60vh', borderRadius: '10px', overflow: 'hidden' }}
/>
);
}

View File

@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from "../../hooks/useAuth";
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faSignIn,
faUser,
faSignOut,
faHouse,
faList,
faBullhorn,
faFile
} from '@fortawesome/free-solid-svg-icons';
import '../../css/NavBar.css';
import NavGestion from './NavGestion';
import ThemeButton from '../ThemeButton.jsx';
import IfAuthenticated from '../Auth/IfAuthenticated.jsx';
import IfNotAuthenticated from '../Auth/IfNotAuthenticated.jsx';
import IfRole from '../Auth/IfRole.jsx';
import { Navbar, Nav, Container } from 'react-bootstrap';
import AnimatedDropdown from '../AnimatedDropdown.jsx';
import { CONSTANTS } from '../../util/constants.js';
const NavBar = () => {
const { user, logout } = useAuth();
const [showingUserDropdown, setShowingUserDropdown] = useState(false);
const [expanded, setExpanded] = useState(false);
const [isLg, setIsLg] = useState(window.innerWidth >= 992);
useEffect(() => {
const handleResize = () => {
setIsLg(window.innerWidth >= 992 && window.innerWidth < 1200);
};
handleResize(); // inicializar
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= 992) {
setExpanded(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
return (
<Navbar expand="lg" sticky="top" expanded={expanded} onToggle={() => setExpanded(!expanded)}>
<Container fluid>
<Navbar.Toggle aria-controls="navbar" className="custom-toggler">
<svg width="30" height="30" viewBox="0 0 30 30">
<path
d="M4 7h22M4 15h22M4 23h22"
stroke="var(--navbar-link-color)"
strokeWidth="3"
strokeLinecap="round"
strokeMiterlimit="10"
/>
</svg>
</Navbar.Toggle>
<Navbar.Collapse id="main-navbar">
<Nav className="me-auto gap-2">
<Nav.Link
as={Link}
to="/"
title="Inicio"
href="/"
className={`text-truncate ${expanded ? "mt-3" : ""}`}
onClick={() => setExpanded(false)}
>
<FontAwesomeIcon icon={faHouse} className="me-2" />
Inicio
</Nav.Link>
<Nav.Link
as={Link}
to="/lista-espera"
title="Lista de espera"
className={`text-truncate ${expanded ? "mt-3" : ""}`}
onClick={() => setExpanded(false)}
>
<FontAwesomeIcon icon={faList} className="me-2" />
Lista de espera
</Nav.Link>
<IfAuthenticated>
<Nav.Link
as={Link}
to="/anuncios"
title="Anuncios"
className={`text-truncate ${expanded ? "mt-3" : ""}`}
onClick={() => setExpanded(false)}
>
<FontAwesomeIcon icon={faBullhorn} className="me-2" />Anuncios
</Nav.Link>
<Nav.Link
as={Link}
to="/documentacion"
title="Documentación"
className={`text-truncate ${expanded ? "mt-3" : ""}`}
onClick={() => setExpanded(false)}
>
<FontAwesomeIcon icon={faFile} className="me-2" />Documentación
</Nav.Link>
</IfAuthenticated>
<IfRole roles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
<NavGestion onNavigate={() => setExpanded(false)} externalExpanded={expanded} />
</IfRole>
<div className="d-lg-none mt-2 ms-2">
<ThemeButton onlyIcon={isLg} />
</div>
</Nav>
</Navbar.Collapse>
<div className="d-none d-lg-block me-3">
<ThemeButton onlyIcon={isLg} />
</div>
<Nav className="d-flex flex-md-row flex-column gap-2 ms-auto align-items-center">
<IfAuthenticated>
<AnimatedDropdown
className='end-0 position-absolute'
show={showingUserDropdown}
onMouseEnter={() => setShowingUserDropdown(true)}
onMouseLeave={() => setShowingUserDropdown(false)}
onToggle={(isOpen) => setShowingUserDropdown(isOpen)}
trigger={
<Link className="nav-link dropdown-toggle fw-bold">
@{user?.user_name}
</Link>
}
>
<Link to="/perfil" className="text-muted dropdown-item nav-link">
<FontAwesomeIcon icon={faUser} className="me-2" />
Mi perfil
</Link>
<hr className="dropdown-divider" />
<Link to="#" className="dropdown-item nav-link" onClick={logout}>
<FontAwesomeIcon icon={faSignOut} className="me-2" />
Cerrar sesión
</Link>
</AnimatedDropdown>
</IfAuthenticated>
<IfNotAuthenticated>
<Nav.Link as={Link} to="/login" title="Iniciar sesión">
<FontAwesomeIcon icon={faSignIn} className="me-2" />
Iniciar sesión
</Nav.Link>
</IfNotAuthenticated>
</Nav>
</Container>
</Navbar>
);
};
export default NavBar;

View File

@@ -0,0 +1,67 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import AnimatedDropdown from '../../components/AnimatedDropdown';
import AnimatedDropend from '../../components/AnimatedDropend';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faGear, faUsers, faMoneyBill, faWallet, faFileInvoice,
faEnvelope,
faBellConcierge,
faPeopleGroup
} from '@fortawesome/free-solid-svg-icons';
import useRequestCount from '../../hooks/useRequestCount';
const NavGestion = ({ onNavigate, externalExpanded }) => {
const [showing, setShowing] = useState(false);
const count = useRequestCount();
return (
<AnimatedDropdown
show={showing}
onMouseEnter={() => setShowing(true)}
onMouseLeave={() => setShowing(false)}
onToggle={(isOpen) => setShowing(isOpen)}
trigger={
<Link className={`nav-link dropdown-toggle ${externalExpanded ? "mt-3" : ""}`} role="button">
<FontAwesomeIcon icon={faGear} className="me-2" />Gestión
</Link>
}
>
{/* Submenú lateral: Asociación */}
<AnimatedDropend
trigger={
<Link className="nav-link dropdown-toggle" role='button'>
<FontAwesomeIcon icon={faPeopleGroup} className="me-2" />Asociación
</Link>
}
>
<Link to="/gestion/socios" className="dropdown-item nav-link" onClick={onNavigate}>
<FontAwesomeIcon icon={faUsers} className="me-2" />Socios
</Link>
<Link to="/gestion/ingresos" className="dropdown-item nav-link" onClick={onNavigate}>
<FontAwesomeIcon icon={faMoneyBill} className="me-2" />Ingresos
</Link>
<Link to="/gestion/gastos" className="dropdown-item nav-link" onClick={onNavigate}>
<FontAwesomeIcon icon={faWallet} className="me-2" />Gastos
</Link>
<Link to="/gestion/balance" className="dropdown-item nav-link" onClick={onNavigate}>
<FontAwesomeIcon icon={faFileInvoice} className="me-2" />Balance
</Link>
</AnimatedDropend>
<Link to="/gestion/solicitudes" className="dropdown-item nav-link" onClick={onNavigate}>
<FontAwesomeIcon icon={faBellConcierge} />
<span className="icon-with-badge">
{count > 0 && <span className="icon-badge">{count}</span>}
</span>&nbsp;
Solicitudes
</Link>
<Link to="/correo" className="dropdown-item nav-link" onClick={onNavigate}>
<FontAwesomeIcon icon={faEnvelope} className="me-2" />Correo
</Link>
</AnimatedDropdown>
);
};
export default NavGestion;

View File

@@ -0,0 +1,69 @@
import PropTypes from 'prop-types';
import { Modal, Button } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCircleCheck,
faCircleXmark,
faCircleExclamation,
faCircleInfo
} from '@fortawesome/free-solid-svg-icons';
const iconMap = {
success: faCircleCheck,
danger: faCircleXmark,
warning: faCircleExclamation,
info: faCircleInfo
};
const NotificationModal = ({
show,
onClose,
title,
message,
variant = "info",
buttons = [{ label: "Aceptar", variant: "primary", onClick: onClose }]
}) => {
return (
<Modal show={show} onHide={onClose} centered>
<Modal.Header closeButton className={`bg-${variant} ${variant === 'info' ? 'text-dark' : 'text-white'}`}>
<Modal.Title>
<FontAwesomeIcon icon={iconMap[variant] || faCircleInfo} className="me-2" />
{title}
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="mb-0">{message}</p>
</Modal.Body>
<Modal.Footer>
{buttons.map((btn, index) => (
<Button
key={index}
variant={btn.variant || "primary"}
onClick={btn.onClick || onClose}
>
{btn.label}
</Button>
))}
</Modal.Footer>
</Modal>
);
};
NotificationModal.propTypes = {
show: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
variant: PropTypes.oneOf(['success', 'danger', 'warning', 'info']),
buttons: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
variant: PropTypes.string,
onClick: PropTypes.func
})
)
};
export default NotificationModal;

View File

@@ -0,0 +1,22 @@
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Modal, Button } from "react-bootstrap";
import { PDFViewer } from "@react-pdf/renderer";
const PDFModal = ({ show, onClose, title, children }) => (
<Modal show={show} onHide={onClose} size="xl" centered>
<Modal.Header className='justify-content-between'>
<Modal.Title>{title}</Modal.Title>
<Button variant='transparent' onClick={onClose}>
<FontAwesomeIcon icon={faXmark} className='close-button fa-xl' />
</Button>
</Modal.Header>
<Modal.Body className="rounded-bottom-4 p-0" style={{ height: '80vh' }}>
<PDFViewer width="100%" height="100%">
{children}
</PDFViewer>
</Modal.Body>
</Modal>
);
export default PDFModal;

View File

@@ -0,0 +1,24 @@
import LoadingIcon from './LoadingIcon';
const PaginatedCardGrid = ({
items = [],
renderCard,
creatingItem = null,
renderCreatingCard = null,
loaderRef,
loading = false
}) => {
return (
<div className="cards-grid">
{creatingItem && renderCreatingCard && renderCreatingCard()}
{items.map((item, i) => renderCard(item, i))}
<div ref={loaderRef} className="loading-trigger d-flex justify-content-center align-items-center">
{loading && <LoadingIcon />}
</div>
</div>
);
};
export default PaginatedCardGrid;

View File

@@ -0,0 +1,43 @@
import { faFilter, faFilePdf, faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import AnimatedDropdown from './AnimatedDropdown';
import Button from 'react-bootstrap/Button';
import { CONSTANTS } from '../util/constants';
import IfRole from './Auth/IfRole';
const SearchToolbar = ({ searchTerm, onSearchChange, filtersComponent, onCreate, onPDF }) => (
<div className="sticky-toolbar search-toolbar-wrapper">
<div className="search-toolbar">
<input
type="text"
className="search-input"
placeholder="Buscar..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
/>
<div className="toolbar-buttons">
{filtersComponent && (
<AnimatedDropdown variant="transparent" icon={<FontAwesomeIcon icon={faFilter} className='fa-md' />}>
{filtersComponent}
</AnimatedDropdown>
)}
{onPDF && (
<IfRole roles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
<Button variant="transparent" onClick={onPDF}>
<FontAwesomeIcon icon={faFilePdf} className='fa-md' />
</Button>
</IfRole>
)}
{onCreate && (
<IfRole roles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
<Button variant="transparent" onClick={onCreate}>
<FontAwesomeIcon icon={faPlus} className='fa-md' />
</Button>
</IfRole>
)}
</div>
</div>
</div>
);
export default SearchToolbar;

View File

@@ -0,0 +1,364 @@
import { useEffect, useState } from 'react';
import {
Card, ListGroup, Badge, Button, Form,
Tooltip, OverlayTrigger
} from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faIdCard, faUser, faSunPlantWilt, faPhone, faClipboard, faAt,
faEllipsisVertical, faEdit, faTrash, faMoneyBill,
faCheck,
faXmark,
faCalendar,
faKey
} from '@fortawesome/free-solid-svg-icons';
import { motion as _motion } from 'framer-motion';
import PropTypes from 'prop-types';
import AnimatedDropdown from '../../components/AnimatedDropdown';
import '../../css/SocioCard.css';
import TipoSocioDropdown from './TipoSocioDropdown';
import { getNowAsLocalDatetime } from '../../util/date';
import { generateSecurePassword } from '../../util/passwordGenerator';
import { DateParser } from '../../util/parsers/dateParser';
import { renderErrorAlert } from '../../util/alertHelpers';
import { useDataContext } from "../../hooks/useDataContext";
import SpanishDateTimePicker from '../SpanishDateTimePicker';
const renderDateField = (label, icon, dateValue, editMode, fieldKey, handleChange) => {
if (!editMode && !dateValue) return null;
return (
<ListGroup.Item className="d-flex justify-content-between align-items-center">
<span><FontAwesomeIcon icon={icon} className="me-2" />{label}</span>
{editMode ? (
<SpanishDateTimePicker
selected={dateValue ? new Date(dateValue) : null}
onChange={(date) =>
date ? handleChange(fieldKey, date.toISOString().slice(0, 16)) : handleChange(fieldKey, null)
}
/>
) : (
<strong>{DateParser.isoToStringWithTime(dateValue)}</strong>
)}
</ListGroup.Item>
);
};
const getFechas = (formData, editMode, handleChange) => {
const { created_at, assigned_at, deactivated_at } = formData;
// Si no hay fechas y no está en modo edición, no muestres nada
if (!editMode && !created_at && !assigned_at && !deactivated_at) return null;
return (
<ListGroup className="mt-2 border-1 rounded-3 shadow-sm">
{renderDateField("ALTA", faCalendar, created_at, editMode, "created_at", handleChange)}
{renderDateField("ENTREGA", faCalendar, assigned_at, editMode, "assigned_at", handleChange)}
{renderDateField("BAJA", faCalendar, deactivated_at, editMode, "deactivated_at", handleChange)}
</ListGroup>
);
};
const getBadgeColor = (estado) => estado === 1 ? 'success' : 'danger';
const getHeaderColor = (estado) => estado === 1 ? 'bg-light-green' : 'bg-light-red';
const getEstado = (estado) =>
estado === 1 ? (
<>
<FontAwesomeIcon icon={faCheck} className="me-2" />
ACTIVO
</>
) : (
<>
<FontAwesomeIcon icon={faXmark} className="me-2" />
INACTIVO
</>
);
const parseNull = (attr) => attr === null || attr === '' ? 'NO' : attr;
const getPFP = (tipo) => {
const base = '/images/icons/';
const map = {
1: 'farmer.svg',
2: 'green_house.svg',
0: 'list.svg',
3: 'join.svg',
4: 'subvencion4.svg',
5: 'programmer.svg'
};
return base + (map[tipo] || 'farmer.svg');
};
const MotionCard = _motion.create(Card);
const SocioCard = ({ socio, isNew = false, onCreate, onUpdate, onDelete, onCancel, onViewIncomes, error, onClearError, positionIfWaitlist }) => {
const createMode = isNew;
const [editMode, setEditMode] = useState(isNew);
const [showPassword, setShowPassword] = useState(false);
const [latestNumber, setLatestNumber] = useState(null);
const { getData } = useDataContext();
const [formData, setFormData] = useState({
display_name: socio.display_name,
user_name: socio.user_name,
email: socio.email || '',
dni: socio.dni,
phone: socio.phone,
member_number: socio.member_number || latestNumber,
plot_number: socio.plot_number,
notes: socio.notes || '',
status: socio.status,
type: socio.type,
created_at: socio.created_at?.slice(0, 16) || (isNew ? getNowAsLocalDatetime() : ''),
assigned_at: socio.assigned_at?.slice(0, 16) || undefined,
deactivated_at: socio.deactivated_at?.slice(0, 16) || undefined,
global_role: 0,
password: createMode && !editMode ? generateSecurePassword() : null,
});
useEffect(() => {
if (!editMode) {
setFormData({
display_name: socio.display_name,
user_name: socio.user_name,
email: socio.email || '',
dni: socio.dni,
phone: socio.phone,
member_number: socio.member_number,
plot_number: socio.plot_number,
notes: socio.notes || '',
status: socio.status,
type: socio.type,
created_at: socio.created_at?.slice(0, 16) || (isNew ? getNowAsLocalDatetime() : ''),
assigned_at: socio.assigned_at?.slice(0, 16) || undefined,
deactivated_at: socio.deactivated_at?.slice(0, 16) || undefined,
global_role: 0,
password: createMode ? generateSecurePassword() : ''
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [socio, editMode]);
useEffect(() => {
const fetchLastNumber = async () => {
try {
if (!(createMode || editMode)) return;
const { data, error } = await getData("https://api.huertosbellavista.es/v1/members/latest-number");
if (error) throw new Error(error);
const nuevoNumero = data.lastMemberNumber + 1;
setLatestNumber(nuevoNumero);
setFormData(prev => ({
...prev,
member_number: prev.member_number || nuevoNumero
}));
} catch (err) {
console.error("Error al obtener el número de socio:", err);
}
};
fetchLastNumber();
}, [createMode, editMode, getData]);
const handleEdit = () => {
if (onClearError) onClearError();
setEditMode(true);
};
const handleDelete = () => typeof onDelete === "function" && onDelete(socio.user_id);
const handleCancel = () => {
if (onClearError) onClearError();
if (isNew && typeof onCancel === 'function') return onCancel();
setEditMode(false);
};
const handleSave = () => {
if (onClearError) onClearError();
const newSocio = { ...socio, ...formData };
if (createMode && typeof onCreate === 'function') return onCreate(newSocio);
if (typeof onUpdate === 'function') return onUpdate(newSocio, socio.user_id);
};
const handleChange = (field, value) => {
if (["member_number"].includes(field)) {
value = value === "" ? latestNumber : parseInt(value);
}
if (field === "display_name") {
value = value.toUpperCase();
}
if (field === "dni") {
value = value.toUpperCase();
}
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleViewIncomes = () => {
onViewIncomes(socio.user_id);
}
return (
<MotionCard className="socio-card shadow-sm rounded-4 h-100">
<Card.Header className={`d-flex align-items-center rounded-4 rounded-bottom-0 justify-content-between ${getHeaderColor(formData.status)}`}>
<div className="d-flex align-items-center p-1 m-0">
{editMode ? (
<TipoSocioDropdown value={formData.type} onChange={(val) => handleChange('type', val)} />
) : (
positionIfWaitlist && socio.type === 0 ? (
<OverlayTrigger
placement="top"
overlay={
<Tooltip>
<strong>{positionIfWaitlist}</strong> en la lista de espera
</Tooltip>
}>
<span className="me-3">
<img src={getPFP(formData.type)} width="36" className="rounded" alt="PFP" />
</span>
</OverlayTrigger>
) : (
<img src={getPFP(formData.type)} width="36" className="rounded me-3" alt="PFP" />
)
)}
<div className='d-flex flex-column gap-1'>
<Card.Title className="m-0">
{editMode ? (
<Form.Control className="themed-input" size="sm" value={formData.display_name} onChange={(e) => handleChange('display_name', e.target.value)} style={{ maxWidth: '220px' }} />
) : formData.display_name}
</Card.Title>
{editMode ? (
<Form.Select className="themed-input" size="sm" value={formData.status} onChange={(e) => handleChange('status', parseInt(e.target.value))} style={{ maxWidth: '8rem' }}>
<option value={1}>ACTIVO</option>
<option value={0}>INACTIVO</option>
</Form.Select>
) : (
<Badge style={{ width: 'fit-content' }} bg={getBadgeColor(formData.status)}>{getEstado(formData.status)}</Badge>
)}
</div>
</div>
{!createMode && !editMode && (
<AnimatedDropdown
className='end-0'
buttonStyle='card-button'
icon={<FontAwesomeIcon icon={faEllipsisVertical} className="fa-xl" />}>
{({ closeDropdown }) => (
<>
<div className="dropdown-item d-flex align-items-center" onClick={() => { handleEdit(); closeDropdown(); }}>
<FontAwesomeIcon icon={faEdit} className="me-2" />Editar
</div>
<div className="dropdown-item d-flex align-items-center" onClick={() => { handleViewIncomes(); closeDropdown(); }}>
<FontAwesomeIcon icon={faMoneyBill} className="me-2" />Ver ingresos
</div>
<hr className="dropdown-divider" />
<div className="dropdown-item d-flex align-items-center text-danger" onClick={() => { handleDelete(); closeDropdown(); }}>
<FontAwesomeIcon icon={faTrash} className="me-2" />Eliminar
</div>
</>
)}
</AnimatedDropdown>
)}
</Card.Header>
<Card.Body>
{(editMode || createMode) && renderErrorAlert(error)}
<ListGroup className="mt-2 border-1 rounded-3 shadow-sm">
{[{
label: 'DNI', clazz: '', icon: faIdCard, value: formData.dni, field: 'dni', type: 'text', maxWidth: '180px'
}, {
label: 'SOCIO Nº', clazz: '', icon: faUser, value: formData.member_number || latestNumber, field: 'member_number', type: 'number', maxWidth: '100px'
}, {
label: 'HUERTO Nº', clazz: '', icon: faSunPlantWilt, value: formData.plot_number, field: 'plot_number', type: 'number', maxWidth: '100px'
}, {
label: 'TLF.', clazz: '', icon: faPhone, value: formData.phone, field: 'phone', type: 'number', maxWidth: '200px'
}, {
label: 'EMAIL', clazz: 'text-truncate', icon: faAt, value: formData.email, field: 'email', type: 'text', maxWidth: '250px'
}].map(({ label, clazz, icon, value, field, type, maxWidth }) => (
<ListGroup.Item key={field} className="d-flex justify-content-between align-items-center">
<span><FontAwesomeIcon icon={icon} className="me-2" />{label}</span>
{editMode ? (
<Form.Control className="themed-input" size="sm" type={type} value={value} onChange={(e) => handleChange(field, e.target.value)} style={{ maxWidth }} />
) : (
<strong className={clazz}>{parseNull(value)}</strong>
)}
</ListGroup.Item>
))}
{editMode && (
<ListGroup.Item className="d-flex justify-content-between align-items-center">
<span><FontAwesomeIcon icon={faKey} className="me-2" />CONTRASEÑA</span>
<div className="d-flex align-items-center gap-2" style={{ maxWidth: 'fit-content' }}>
<Form.Control
className="themed-input"
size="sm"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) => handleChange('password', e.target.value)}
style={{ maxWidth: '200px' }}
/>
<Button
size="sm"
variant="outline-secondary"
onClick={() => setShowPassword(prev => !prev)}
>
{showPassword ? "Ocultar" : "Mostrar"}
</Button>
<Button
size="sm"
variant="outline-secondary"
onClick={() => handleChange('password', generateSecurePassword())}
>
Generar
</Button>
</div>
</ListGroup.Item>
)}
</ListGroup>
{getFechas(formData, editMode, handleChange)}
<Card className="mt-2 border-1 rounded-3 notas-card">
<Card.Body>
<Card.Subtitle className="mb-2">
{editMode ? (
<><FontAwesomeIcon icon={faClipboard} className="me-2" />NOTAS (máx. 256)</>
) : (
<><FontAwesomeIcon icon={faClipboard} className="me-2" />NOTAS</>
)}
</Card.Subtitle>
{editMode ? (
<Form.Control className="themed-input" as="textarea" rows={3} value={formData.notes} onChange={(e) => handleChange('notes', e.target.value)} />
) : (
<Card.Text>{parseNull(formData.notes)}</Card.Text>
)}
</Card.Body>
</Card>
{editMode && (
<div className="d-flex justify-content-end gap-2 mt-3">
<Button variant="danger" size="sm" onClick={handleCancel}>Cancelar</Button>
<Button variant="success" size="sm" onClick={handleSave}>Guardar</Button>
</div>
)}
</Card.Body>
</MotionCard>
);
};
SocioCard.propTypes = {
socio: PropTypes.object.isRequired,
isNew: PropTypes.bool,
onCancel: PropTypes.func,
onCreate: PropTypes.func,
onUpdate: PropTypes.func,
onDelete: PropTypes.func,
onViewIncomes: PropTypes.func,
error: PropTypes.string,
onClearError: PropTypes.func,
positionIfWaitlist: PropTypes.number
};
export default SocioCard;

View File

@@ -0,0 +1,104 @@
import PropTypes from 'prop-types';
const SociosFilter = ({ filters, onChange }) => {
const handleCheckboxChange = (key) => {
if (key === 'todos') {
const newValue = !filters.todos;
onChange({
todos: newValue,
listaEspera: newValue,
invernadero: newValue,
inactivos: newValue,
colaboradores: newValue,
hortelanos: newValue
});
} else {
const updated = { ...filters, [key]: !filters[key] };
const allTrue = Object.entries(updated)
.filter(([k]) => k !== 'todos')
.every(([, v]) => v === true);
updated.todos = allTrue;
onChange(updated);
}
};
return (
<>
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="mostrarTodosCheck"
className="me-2"
checked={filters.todos}
onChange={() => handleCheckboxChange('todos')}
/>
<label htmlFor="mostrarTodosCheck" className="m-0">Mostrar Todos</label>
</div>
<hr className="dropdown-divider" />
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="esperaCheck"
className="me-2"
checked={filters.listaEspera}
onChange={() => handleCheckboxChange('listaEspera')}
/>
<label htmlFor="esperaCheck" className="m-0">Lista de Espera</label>
</div>
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="invernaderosCheck"
className="me-2"
checked={filters.invernadero}
onChange={() => handleCheckboxChange('invernadero')}
/>
<label htmlFor="invernaderosCheck" className="m-0">Invernadero</label>
</div>
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="inactivosCheck"
className="me-2"
checked={filters.inactivos}
onChange={() => handleCheckboxChange('inactivos')}
/>
<label htmlFor="inactivosCheck" className="m-0">Inactivos</label>
</div>
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="colaboradoresCheck"
className="me-2"
checked={filters.colaboradores}
onChange={() => handleCheckboxChange('colaboradores')}
/>
<label htmlFor="colaboradoresCheck" className="m-0">Colaboradores</label>
</div>
<div className="dropdown-item d-flex align-items-center">
<input
type="checkbox"
id="hortelanosCheck"
className="me-2"
checked={filters.hortelanos}
onChange={() => handleCheckboxChange('hortelanos')}
/>
<label htmlFor="hortelanosCheck" className="m-0">Hortelanos</label>
</div>
</>
);
};
SociosFilter.propTypes = {
filters: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired
};
export default SociosFilter;

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { Document, Page, Text, View, StyleSheet, Font, Image } from '@react-pdf/renderer';
Font.register({
family: 'Open Sans',
fonts: [{ src: '/fonts/OpenSans.ttf', fontWeight: 'normal' }]
});
const styles = StyleSheet.create({
page: {
padding: 25,
fontSize: 14,
fontFamily: 'Open Sans',
backgroundColor: '#F8F9FA',
},
headerContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 25,
justifyContent: 'left',
},
headerText: {
flexDirection: 'column',
justifyContent: 'left',
alignItems: 'center',
marginLeft: 25,
},
logo: {
width: 60,
height: 60,
},
header: {
fontSize: 28,
fontWeight: 'bold',
color: '#2C3E50',
letterSpacing: 1.5,
},
subHeader: {
fontSize: 14,
marginTop: 5,
color: '#34495E'
},
tableHeader: {
flexDirection: 'row',
backgroundColor: '#3E8F5A',
fontWeight: 'bold',
paddingVertical: 8,
paddingHorizontal: 5,
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
},
headerCell: {
paddingHorizontal: 5,
color: '#ffffff',
fontWeight: 'bold',
fontSize: 12,
},
row: {
flexDirection: 'row',
paddingVertical: 6,
paddingHorizontal: 5,
borderBottomWidth: 1,
borderBottomColor: '#D5D8DC'
},
cell: {
paddingHorizontal: 5,
fontSize: 9,
color: '#2C3E50'
}
});
const parseDate = (dateStr) => {
if (!dateStr) return '';
const [y, m, d] = dateStr.split('-');
return `${d}/${m}/${y}`;
};
export const SociosPDF = ({ socios }) => (
<Document>
<Page size="A4" orientation="landscape" style={styles.page}>
<View style={styles.headerContainer}>
<Image src={"/images/logo.png"} style={styles.logo} />
<View style={styles.headerText}>
<Text style={styles.header}>Listado de socios</Text>
<Text style={styles.subHeader}>Asociación Huertos La Salud - Bellavista Generado el {new Date().toLocaleDateString()} a las {new Date().toLocaleTimeString()}</Text>
</View>
</View>
<View style={styles.tableHeader}>
<Text style={[styles.headerCell, { flex: 0.2 }]}>S</Text>
<Text style={[styles.headerCell, { flex: 0.2 }]}>H</Text>
<Text style={[styles.headerCell, { flex: 3 }]}>Nombre</Text>
<Text style={[styles.headerCell, { flex: 1 }]}>DNI</Text>
<Text style={[styles.headerCell, { flex: 1 }]}>Teléfono</Text>
<Text style={[styles.headerCell, { flex: 3 }]}>Email</Text>
<Text style={[styles.headerCell, { flex: 1 }]}>Alta</Text>
<Text style={[styles.headerCell, { flex: 1 }]}>Tipo</Text>
</View>
{socios.map((socio, idx) => (
<View
key={idx}
style={[
styles.row,
{ backgroundColor: idx % 2 === 0 ? '#ECF0F1' : '#FDFEFE' },
{ borderBottomLeftRadius: idx === socios.length - 1 ? 10 : 0 },
{ borderBottomRightRadius: idx === socios.length - 1 ? 10 : 0 },
]}
>
<Text style={[styles.cell, { flex: 0.2 }]}>{socio?.member_number}</Text>
<Text style={[styles.cell, { flex: 0.2 }]}>{socio?.plot_number}</Text>
<Text style={[styles.cell, { flex: 3 }]}>{socio?.display_name}</Text>
<Text style={[styles.cell, { flex: 1 }]}>{socio?.dni}</Text>
<Text style={[styles.cell, { flex: 1 }]}>{socio?.phone}</Text>
<Text style={[styles.cell, { flex: 3 }]}>{socio?.email || ''}</Text>
<Text style={[styles.cell, { flex: 1 }]}>{parseDate(socio?.created_at?.split('T')[0] || '')}</Text>
<Text style={[styles.cell, { flex: 1 }]}>
{(() => {
switch (socio?.type) {
case 0: return 'L. Espera';
case 1: return 'Hortelano';
case 2: return 'Invernadero';
case 3: return 'Colaborador';
default: return 'Desconocido';
}
})()}
</Text>
</View>
))}
</Page>
</Document>
);

View File

@@ -0,0 +1,54 @@
import AnimatedDropdown from '../AnimatedDropdown';
import { Image } from 'react-bootstrap';
const tipos = [
{ value: 0, label: 'Lista de espera', icon: 'list.svg' },
{ value: 1, label: 'Hortelano', icon: 'farmer.svg' },
{ value: 2, label: 'Hortelano+Invernadero', icon: 'green_house.svg' },
{ value: 3, label: 'Colaborador', icon: 'join.svg' },
{ value: 4, label: 'Subvención', icon: 'subvencion4.svg' },
{ value: 5, label: 'Informático', icon: 'programmer.svg' }
];
const basePath = '/images/icons/';
const TipoSocioDropdown = ({ value, onChange }) => {
const selected = tipos.find(t => t.value === value) || tipos[0];
return (
<AnimatedDropdown
trigger={
<button className="btn p-0 border-0 bg-transparent">
<Image
src={basePath + selected.icon}
width={36}
className="rounded me-3"
alt={selected.label}
/>
</button>
}
className="w-auto"
>
{({ closeDropdown }) => (
<>
{tipos.map(t => (
<div
key={t.value}
className={`dropdown-item d-flex align-items-center`}
style={{ width: '100%', minWidth: '160px' }}
onClick={() => {
onChange(t.value);
closeDropdown();
}}
>
<img src={basePath + t.icon} width={24} height={24} alt={t.label} className='me-3' />
{t.label}
</div>
))}
</>
)}
</AnimatedDropdown>
);
};
export default TipoSocioDropdown;

View File

@@ -0,0 +1,148 @@
import { useState, useEffect, useRef } from 'react';
import { Form, Row, Col, Button } from 'react-bootstrap';
import { useDataContext } from '../../hooks/useDataContext';
import { Alert } from 'react-bootstrap';
import PropTypes from 'prop-types';
const PreUserForm = ({ onSubmit, userType, plotNumber, errors = {} }) => {
const { getData } = useDataContext();
const fetchedOnce = useRef(false);
const [form, setForm] = useState({
user_name: '',
display_name: '',
dni: '',
phone: '',
email: '',
address: '',
zip_code: '',
city: '',
member_number: '',
plot_number: plotNumber,
type: userType,
status: 1,
role: 0
});
useEffect(() => {
const fetchLastNumber = async () => {
if (fetchedOnce.current) return;
fetchedOnce.current = true;
try {
const { data, error } = await getData("https://api.huertosbellavista.es/v1/members/latest-number");
if (error) throw new Error(error);
setForm((prev) => ({
...prev,
member_number: data.lastMemberNumber + 1
}));
} catch (err) {
console.error("Error al obtener el número de socio:", err);
}
};
fetchLastNumber();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const trimmedName = form.display_name?.trim() ?? "";
const nuevoUsername = trimmedName
? trimmedName.split(' ')[0].toLowerCase() : "";
if (form.user_name !== nuevoUsername) {
setForm(prev => ({ ...prev, user_name: nuevoUsername }));
}
}, [form.member_number, form.display_name, form.user_name]);
const handleChange = (e) => {
const { name, value, type } = e.target;
let updatedValue = value;
if (name === 'display_name' || name === 'dni') {
updatedValue = value.toUpperCase();
}
setForm((prev) => ({
...prev,
[name]: type === 'number' ? parseInt(updatedValue) || '' : updatedValue
}));
};
const handleSubmit = (e) => {
e.preventDefault();
if (onSubmit) onSubmit(form);
};
return (
<>
{errors.general && <Alert variant="danger" className="my-2">{errors.general}</Alert>}
<Form onSubmit={handleSubmit} className="p-3 px-md-4">
<Row className="gy-3">
{[
{ label: 'Nombre completo', name: 'display_name', type: 'text', required: true },
{ label: 'Nombre de usuario', name: 'user_name', type: 'text', required: true },
{ label: 'DNI', name: 'dni', type: 'text', required: true, maxLength: 9 },
{ label: 'Teléfono', name: 'phone', type: 'tel', required: true },
{ label: 'Correo electrónico', name: 'email', type: 'email', required: true },
{ label: 'Domicilio', name: 'address', type: 'text' },
{ label: 'Código Postal', name: 'zip_code', type: 'text' },
{ label: 'Ciudad', name: 'city', type: 'text' }
].map(({ label, name, type, required, maxLength }) => (
<Col md={4} key={name}>
<Form.Group>
<Form.Label className="fw-semibold">{label}</Form.Label>
<Form.Control
className="themed-input shadow-sm"
type={type}
name={name}
value={form[name]}
onChange={handleChange}
required={required}
maxLength={maxLength}
isInvalid={!!errors[name]}
/>
<Form.Control.Feedback type="invalid">
{errors[name]}
</Form.Control.Feedback>
</Form.Group>
</Col>
))}
<Col md={4}>
<Form.Group>
<Form.Label className="fw-semibold"> Socio</Form.Label>
<Form.Control
className="shadow-sm"
disabled
type="number"
name="member_number"
value={form.member_number}
onChange={handleChange}
/>
</Form.Group>
</Col>
<Col xs={12} className="text-center mt-3">
<Button type="submit" variant="success" size="lg" className="px-5 shadow-sm">
Enviar solicitud
</Button>
</Col>
</Row>
</Form>
</>
);
};
PreUserForm.propTypes = {
userType: PropTypes.number.isRequired,
plotNumber: PropTypes.number.isRequired,
onSubmit: PropTypes.func.isRequired,
errors: PropTypes.object
};
export default PreUserForm;

View File

@@ -0,0 +1,197 @@
import { Card, ListGroup, Button } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faUser, faIdCard, faEnvelope, faPhone, faHome, faMapMarkerAlt, faHashtag,
faSeedling, faUserShield, faCalendar,
faTrash, faEllipsisVertical
} from '@fortawesome/free-solid-svg-icons';
import PropTypes from 'prop-types';
import { motion as _motion } from 'framer-motion';
import AnimatedDropdown from '../../components/AnimatedDropdown';
import '../../css/SolicitudCard.css';
const MotionCard = _motion.create(Card);
const parseDate = (date) => {
if (!date) return 'NO';
const d = new Date(date);
return `${d.getDate().toString().padStart(2, '0')}/${(d.getMonth() + 1).toString().padStart(2, '0')}/${d.getFullYear()}`;
};
const getTipoSolicitud = (tipo) => ['Alta', 'Baja', 'Añadir Colaborador', 'Quitar Colaborador', 'Añadir parcela invernadero', 'Dejar parcela invernadero'][tipo] ?? 'Desconocido';
const getEstadoSolicitud = (estado) => ['Pendiente', 'Aceptada', 'Rechazada'][estado] ?? 'Desconocido';
const getPFP = (tipo) => {
const base = '/images/icons/';
const map = {
1: 'farmer.svg',
2: 'green_house.svg',
0: 'list.svg',
3: 'join.svg',
4: 'subvencion4.svg',
5: 'programmer.svg'
};
return base + (map[tipo] || 'farmer.svg');
};
const renderDescripcionSolicitud = (data, onProfile) => {
const { request_type, request_status, requested_by_name, pre_display_name } = data;
switch (request_type) {
case 0:
if (requested_by_name) {
return `${requested_by_name} quiere darse de alta.`;
} else if (request_status !== 1 && pre_display_name) {
return `${pre_display_name} quiere darse de alta.`;
} else if (request_status !== 1) {
return `Alguien quiere darse de alta.`;
} else {
return `Se ha aceptado esta solicitud de alta.`;
}
case 1:
return onProfile
? "Has solicitado darte de baja."
: requested_by_name
? `${requested_by_name} quiere darse de baja.`
: request_status !== 1
? `Alguien quiere darse de baja.`
: `Se ha aceptado esta solicitud de baja.`;
case 2:
if (onProfile) {
switch (request_status) {
case 0: return "Has solicitado añadir un colaborador.";
case 1: return "Tu solicitud de colaborador ha sido aceptada.";
case 2: return "Tu solicitud de colaborador ha sido rechazada.";
default: return "Solicitud de colaborador desconocida.";
}
} else {
switch (request_status) {
case 0:
return requested_by_name
? `${requested_by_name} quiere añadir a ${pre_display_name || "un colaborador"} como colaborador.`
: `Alguien quiere añadir a ${pre_display_name || "un colaborador"} como colaborador.`;
case 1:
return `La solicitud de colaborador de ${requested_by_name || "alguien"} ha sido aceptada.`;
case 2:
return `La solicitud de colaborador de ${requested_by_name || "alguien"} ha sido rechazada.`;
default:
return "Solicitud de colaborador desconocida.";
}
}
case 3:
return onProfile
? "Has solicitado quitar tu colaborador."
: requested_by_name
? `${requested_by_name} quiere quitar su colaborador.`
: request_status !== 1
? `Alguien quiere quitar su colaborador.`
: `Se ha aceptado esta solicitud de baja de colaborador.`;
case 4:
return onProfile
? "Has solicitado una parcela en el invernadero."
: requested_by_name
? `${requested_by_name} quiere una parcela en el invernadero.`
: request_status !== 1
? `Alguien quiere una parcela en el invernadero.`
: `Se ha aceptado esta solicitud de parcela en el invernadero.`;
case 5:
return onProfile
? "Has solicitado dejar tu parcela del invernadero."
: requested_by_name
? `${requested_by_name} quiere dejar su parcela del invernadero.`
: request_status !== 1
? `Alguien quiere dejar su parcela del invernadero.`
: `Se ha aceptado esta solicitud de salida del invernadero.`;
default:
return "Tipo de solicitud desconocido.";
}
};
const SolicitudCard = ({ data, onAccept, onReject, onDelete, editable = true, onProfile = false }) => {
const handleDelete = () => typeof onDelete === "function" && onDelete(data.request_id);
return (
<MotionCard className="solicitud-card shadow-sm rounded-4 h-100">
<Card.Header className="rounded-top-4 d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center">
<img src={getPFP(data.pre_type)} width="36" className="rounded me-3" alt="PFP" />
<div>
<Card.Title className="mb-0">
Solicitud #{data.request_id} - {getTipoSolicitud(data.request_type)}
</Card.Title>
<small className='state-small'>Estado: <strong>{getEstadoSolicitud(data.request_status)}</strong></small>
</div>
</div>
{!onProfile && (
<AnimatedDropdown
className="end-0"
buttonStyle="card-button"
icon={<FontAwesomeIcon icon={faEllipsisVertical} className="fa-xl" />}>
{({ closeDropdown }) => (
<div className="dropdown-item d-flex align-items-center text-danger" onClick={() => { handleDelete(); closeDropdown(); }}>
<FontAwesomeIcon icon={faTrash} className="me-2" />Eliminar
</div>
)}
</AnimatedDropdown>
)}
</Card.Header>
<Card.Body>
<ListGroup variant="flush" className="border rounded-3 mb-3">
<ListGroup.Item>
<FontAwesomeIcon icon={faCalendar} className="me-2" />
Fecha de solicitud: <strong>{parseDate(data.request_created_at)}</strong>
</ListGroup.Item>
</ListGroup>
<ListGroup variant="flush" className="border rounded-3 mb-3">
<ListGroup.Item>
{renderDescripcionSolicitud(data, onProfile)}
</ListGroup.Item>
</ListGroup>
{data.pre_display_name && (
<>
<Card.Subtitle className="card-subtitle mt-3 mb-2">Datos del futuro socio</Card.Subtitle>
<ListGroup variant="flush" className="border rounded-3">
<ListGroup.Item><FontAwesomeIcon icon={faUser} className="me-2" />Nombre: <strong>{data.pre_display_name}</strong></ListGroup.Item>
<ListGroup.Item><FontAwesomeIcon icon={faIdCard} className="me-2" />DNI: <strong>{data.pre_dni}</strong></ListGroup.Item>
<ListGroup.Item><FontAwesomeIcon icon={faPhone} className="me-2" />Teléfono: <strong>{data.pre_phone}</strong></ListGroup.Item>
<ListGroup.Item><FontAwesomeIcon icon={faEnvelope} className="me-2" />Email: <strong>{data.pre_email}</strong></ListGroup.Item>
<ListGroup.Item><FontAwesomeIcon icon={faHome} className="me-2" />Dirección: <strong>{data.pre_address ?? 'NO'}</strong></ListGroup.Item>
<ListGroup.Item><FontAwesomeIcon icon={faMapMarkerAlt} className="me-2" />Ciudad: <strong>{data.pre_city ?? 'NO'} ({data.pre_zip_code ?? 'NO'})</strong></ListGroup.Item>
<ListGroup.Item><FontAwesomeIcon icon={faHashtag} className="me-2" /> socio: <strong>{data.pre_member_number ?? 'NO'}</strong> | huerto: <strong>{data.pre_plot_number ?? 'NO'}</strong></ListGroup.Item>
<ListGroup.Item><FontAwesomeIcon icon={faSeedling} className="me-2" />Tipo: <strong>{['Lista de Espera', 'Hortelano', 'Hortelano + Invernadero', 'Colaborador'][data.pre_type]}</strong></ListGroup.Item>
<ListGroup.Item><FontAwesomeIcon icon={faUserShield} className="me-2" />Rol: <strong>{['Usuario', 'Admin', 'Desarrollador'][data.pre_role]}</strong></ListGroup.Item>
</ListGroup>
</>
)}
{editable && data.request_status === 0 && (
<div className="d-flex justify-content-end gap-2 mt-3">
<Button variant="danger" size="sm" onClick={() => onReject?.(data)}>Rechazar</Button>
<Button variant="success" size="sm" onClick={() => onAccept?.(data)}>Aceptar</Button>
</div>
)}
</Card.Body>
</MotionCard>
);
};
SolicitudCard.propTypes = {
data: PropTypes.object.isRequired,
onAccept: PropTypes.func,
onReject: PropTypes.func,
onDelete: PropTypes.func,
editable: PropTypes.bool,
onProfile: PropTypes.bool
};
export default SolicitudCard;

View File

@@ -0,0 +1,24 @@
import DatePicker, { registerLocale } from 'react-datepicker';
import es from 'date-fns/locale/es';
import 'react-datepicker/dist/react-datepicker.css';
registerLocale('es', es);
const SpanishDateTimePicker = ({ selected, onChange }) => {
return (
<DatePicker
selected={selected}
onChange={onChange}
showTimeSelect
timeFormat="HH:mm"
timeIntervals={15}
dateFormat="dd/MM/yyyy HH:mm"
timeCaption="Hora"
locale="es"
className="form-control themed-input"
/>
);
};
export default SpanishDateTimePicker;

View File

@@ -0,0 +1,18 @@
import { useTheme } from "../hooks/useTheme.js";
import "../css/ThemeButton.css";
export default function ThemeButton({ className, onlyIcon}) {
const { theme, toggleTheme } = useTheme();
return (
<button className={`theme-toggle ${className}`} onClick={toggleTheme}>
{
onlyIcon ? (
theme === "dark" ? ("🌞") : ("🌙")
) : (
theme === "dark" ? ("🌞 tema claro") : ("🌙 tema oscuro")
)
}
</button>
);
}

View File

@@ -0,0 +1,98 @@
import { useState, useEffect, createContext } from "react";
import createAxiosInstance from "../api/axiosInstance";
import { useConfig } from "../hooks/useConfig";
export const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const axios = createAxiosInstance();
const { config } = useConfig();
const [user, setUser] = useState(() => JSON.parse(localStorage.getItem("user")) || null);
const [token, setToken] = useState(() => localStorage.getItem("token"));
const [authStatus, setAuthStatus] = useState("checking");
const [error, setError] = useState(null);
useEffect(() => {
if (!config) return;
if (!token) {
setAuthStatus("unauthenticated");
return;
}
const BASE_URL = config.apiConfig.baseUrl;
const VALIDATE_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.validateToken}`;
const checkAuth = async () => {
try {
const res = await axios.get(VALIDATE_URL, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 200) {
setAuthStatus("authenticated");
} else {
logout();
}
} catch (err) {
console.error("Error validando token:", err);
logout();
}
};
checkAuth();
}, [token, config]);
const login = async (formData) => {
setError(null);
const BASE_URL = config.apiConfig.baseUrl;
const LOGIN_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.login}`;
try {
const res = await axios.post(LOGIN_URL, formData);
const { token, member, tokenTime } = res.data.data;
localStorage.setItem("token", token);
localStorage.setItem("user", JSON.stringify(member));
localStorage.setItem("tokenTime", tokenTime);
setToken(token);
setUser(member);
setAuthStatus("authenticated");
} catch (err) {
console.error("Error al iniciar sesión:", err);
let message = "Ha ocurrido un error inesperado.";
if (err.response) {
const { status, data } = err.response;
if (status === 400) {
message = "Usuario o contraseña incorrectos.";
} else if (status === 403) {
message = "Tu cuenta está inactiva o ha sido suspendida.";
} else if (status === 404) {
message = "Usuario no encontrado.";
} else if (data?.message) {
message = data.message;
}
}
setError(message);
throw new Error(message);
}
};
const logout = () => {
localStorage.clear();
setUser(null);
setToken(null);
setAuthStatus("unauthenticated");
};
return (
<AuthContext.Provider value={{ user, token, authStatus, login, logout, error }}>
{children}
</AuthContext.Provider>
);
};

View File

@@ -0,0 +1,41 @@
import { createContext, useState, useEffect } from "react";
import PropTypes from "prop-types";
const ConfigContext = createContext();
export const ConfigProvider = ({ children }) => {
const [config, setConfig] = useState(null);
const [configLoading, setLoading] = useState(true);
const [configError, setError] = useState(null);
useEffect(() => {
const fetchConfig = async () => {
try {
const response = import.meta.env.MODE === 'production'
? await fetch("/config/settings.prod.json")
: await fetch("/config/settings.dev.json");
if (!response.ok) throw new Error("Error al cargar settings.*.json");
const json = await response.json();
setConfig(json);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchConfig();
}, []);
return (
<ConfigContext.Provider value={{ config, configLoading, configError }}>
{children}
</ConfigContext.Provider>
);
};
ConfigProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export {ConfigContext};

View File

@@ -0,0 +1,23 @@
import { createContext } from "react";
import PropTypes from "prop-types";
import { useData } from "../hooks/useData";
export const DataContext = createContext();
export const DataProvider = ({ config, children }) => {
const data = useData(config);
return (
<DataContext.Provider value={data}>
{children}
</DataContext.Provider>
);
};
DataProvider.propTypes = {
config: PropTypes.shape({
baseUrl: PropTypes.string.isRequired,
params: PropTypes.object,
}).isRequired,
children: PropTypes.node.isRequired,
};

View File

@@ -0,0 +1,31 @@
import { createContext, useEffect, useState } from "react";
export const ThemeContext = createContext();
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState(() => {
return (
localStorage.getItem("theme") ||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
);
});
useEffect(() => {
const root = document.documentElement;
document.body.classList.remove("light", "dark");
document.body.classList.add(theme);
root.classList.remove("light", "dark");
root.classList.add(theme);
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};

View File

@@ -0,0 +1,28 @@
.dropdown-menu .dropdown-divider {
border-top: 1px solid var(--divider-color);
}
.dropdown-menu {
background-color: var(--bg-color) !important;
color: var(--text-color) !important;
}
.dropdown-menu.show {
background-color: var(--navbar-bg) !important;
box-shadow: 0 5px 10px var(--shadow-color);
}
.dropdown-item {
background-color: var(--navbar-bg) !important;
color: var(--navbar-dropdown-item-color);
cursor: pointer;
}
.dropdown-item:hover {
background-color: var(--navbar-bg) !important;
color: var(--secondary-color) !important;
}
.disabled.text-muted {
color: var(--muted-color) !important;
}

151
src/css/AnuncioCard.css Normal file
View File

@@ -0,0 +1,151 @@
.anuncio-card {
background-color: var(--card-bg) !important;
color: var(--card-text);
border: none !important;
font-family: "Open Sans", sans-serif;
}
.anuncio-card:hover {
transform: translateY(-5px);
box-shadow: 0 0.75rem 1.5rem var(--shadow-color);
}
.anuncio-card .card-header {
background-color: var(--secondary-color) !important;
color: var(--card-text-secondary) !important;
border-bottom: 1px solid var(--divider-color);
}
.anuncio-card .card-header button {
border: none;
border-radius: 50%;
padding: 0.5rem;
width: 2.25rem;
height: 2.25rem;
background-color: transparent;
color: var(--card-button) !important;
display: flex;
align-items: center;
justify-content: center;
}
.anuncio-card .card-header button:hover {
background-color: var(--header-btn-hover);
}
.anuncio-card .leer-mas-link {
font-weight: 600;
color: var(--accent-color);
}
.anuncio-card .leer-mas-link:hover {
text-decoration: underline;
color: var(--btn-bg-hover);
}
.anuncio-card .text-muted {
color: var(--card-muted-text) !important;
}
.anuncio-card .priority-footer {
padding: 0.5rem 1rem;
font-weight: bold;
border-top: 1px solid var(--divider-color);
color: var(--card-text);
}
.rsw-toolbar {
margin-bottom: 0.5rem;
border-bottom: 1px solid #ccc;
padding-bottom: 0.3rem;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rsw-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
}
.rsw-ce {
padding: 8px;
min-height: 120px;
outline: none;
}
.rsw-dd {
padding: 4px;
border-radius: 6px;
}
.rsw-ce ul {
list-style: disc;
padding-left: 2em;
}
.rsw-ce ol {
list-style: decimal;
padding-left: 2em;
}
/* Estilo base del contenedor del editor */
.rsw-editor {
background-color: var(--input-bg) !important;
border: 1px solid var(--input-border) !important;
border-radius: 8px !important;
padding: 0.5rem !important;
color: var(--input-text) !important;
font-family: "Open Sans", sans-serif !important;
font-size: 0.95rem !important;
}
/* Estilo del área editable */
.rsw-editor textarea {
background-color: transparent !important;
border: none !important;
color: var(--input-text) !important;
font-family: "Open Sans", sans-serif !important;
font-size: 0.95rem !important;
resize: vertical !important;
min-height: 120px !important;
}
/* Placeholder del textarea si lo añades dinámicamente */
.rsw-editor textarea::placeholder {
color: var(--placeholder-color) !important;
}
/* Enfoque del editor */
.rsw-editor:focus-within {
border-color: var(--accent-color) !important;
box-shadow: 0 0 0 0.15rem rgba(67, 167, 72, 0.25);
}
/* Toolbar del editor */
.rsw-toolbar {
background-color: transparent !important;
border-bottom: 1px solid var(--input-border) !important;
padding-bottom: 0.25rem !important;
margin-bottom: 0.5rem !important;
}
/* Botones del toolbar */
.rsw-toolbar button {
background: transparent !important;
border: none !important;
color: var(--toolbar-btn-color) !important;
border-radius: 6px !important;
padding: 0.35rem !important;
}
.rsw-toolbar button:hover {
background-color: var(--toolbar-btn-hover) !important;
}
.rsw-toolbar button:disabled {
opacity: 0.4 !important;
cursor: not-allowed !important;
}

55
src/css/BalanceReport.css Normal file
View File

@@ -0,0 +1,55 @@
.balance-report.card {
border-radius: 1.75rem;
box-shadow: 0 6px 24px var(--shadow-color);
background-color: var(--balance-report-bg);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.report-title {
font-size: 2rem;
font-weight: 700;
color: var(--text-color);
}
.balance-box {
border-radius: 1rem;
background-color: var(--bg-color);
padding: 1.25rem;
color: var(--text-color);
}
.balance-box p {
margin-bottom: 0.5rem;
font-size: 1rem;
}
.balance-value {
font-weight: 600;
color: var(--primary-color);
}
.balance-timestamp {
font-size: 0.9rem;
opacity: 0.7;
}
.print-btn {
border-radius: 999px;
padding: 0.45rem 1.2rem;
font-size: 0.95rem;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.5rem;
background-color: var(--btn-bg);
color: var(--btn-text);
}
.print-btn:hover {
background-color: var(--btn-bg-hover);
color: var(--btn-text-hover);
border: 1px solid var(--border-color);
}

51
src/css/Building.css Normal file
View File

@@ -0,0 +1,51 @@
/* ================================
BUILDING COMPONENT - VISUAL ONLY
================================== */
.building-container {
font-family: 'Product Sans', sans-serif;
color: var(--fg-color);
animation: fadeInScale 0.5s ease;
}
.building-icon {
font-size: 4rem;
margin-bottom: 1rem;
animation: bounce 2s infinite;
}
.building-title {
font-size: 2rem;
font-weight: bold;
}
.building-subtitle {
font-size: 1.2rem;
margin-top: 0.5rem;
opacity: 0.75;
}
/* Animaciones */
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes bounce {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-8px);
}
}

View File

@@ -0,0 +1,60 @@
.compose-mail-modal {
background: #fff;
border-radius: 8px;
padding: 20px;
max-width: 500px;
margin: auto;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.compose-mail-overlay {
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
font-weight: bold;
margin-bottom: 5px;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.btn-cancel {
background: #f5f5f5;
border: 1px solid #ccc;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
}
.btn-send {
background: #007bff;
color: #fff;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
}
.btn-send:hover {
background: #0056b3;
}

65
src/css/Correo.css Normal file
View File

@@ -0,0 +1,65 @@
/* Layout general */
.correo-page {
height: 100vh;
overflow: hidden;
}
.split-wrapper {
display: flex;
height: 100%;
}
.split-wrapper > * {
overflow: hidden;
}
/* Gutter (barra de resize entre paneles) */
.gutter {
background-color: var(--divider-color);
background-clip: content-box;
cursor: col-resize;
width: 1px !important;
}
.gutter:hover {
background-color: var(--highlight-border);
width: 8px !important;
transition: width 0.2s ease-in-out;
}
/* Panel de navegación (Sidebar + MailList) */
.mail-nav-pane {
display: flex;
flex-direction: row;
height: 100%;
width: 100%;
overflow: hidden;
}
.mail-nav-inner {
display: flex;
flex-direction: row;
width: 100%;
overflow: hidden;
}
/* ====================
Modo móvil (correo-mobile)
==================== */
@media screen and (max-width: 900px) {
.split-wrapper {
flex-direction: column;
}
.split-wrapper > * {
width: 100% !important;
}
.correo-page.viewing-mail .split-wrapper > :nth-child(1) {
display: none; /* Oculta panel de navegación */
}
.correo-page.viewing-mail .split-wrapper > :nth-child(2) {
display: block;
}
}

34
src/css/CorreoMobile.css Normal file
View File

@@ -0,0 +1,34 @@
.correo-mobile {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden; /* IMPORTANTE */
}
.mail-list,
.mail-view {
flex-grow: 1;
overflow-y: auto;
padding-inline: 0.75rem;
}
.correo-mobile .mail-toolbar-wrapper {
flex-shrink: 0;
}
.mail-drawer {
position: absolute;
top: 56px;
left: 0;
width: 100%;
height: calc(100% - 56px);
background-color: var(--card-bg);
transform: translateX(-100%);
z-index: 2000;
}
.mail-drawer.open {
transform: translateX(0);
}

View File

@@ -0,0 +1,11 @@
.carousel-img-wrapper {
padding: 0.5rem;
}
.carousel-img {
width: 100%;
height: auto;
border-radius: 1rem;
max-height: 60vh;
object-fit: cover;
}

41
src/css/File.css Normal file
View File

@@ -0,0 +1,41 @@
.file-card {
border: none !important;
}
.file-card .card-body {
border-radius: 12px;
padding: 1rem;
text-align: center;
color: var(--text-color);
background-color: var(--file-card-bg);
cursor: pointer;
overflow: hidden;
}
.file-card:hover {
transform: scale(1.02);
}
.file-card img {
max-width: 48px;
margin-bottom: 0.5rem;
}
.file-card p {
font-size: 0.85rem;
margin: 0;
color: var(--text-color);
}
.file-card .delete-btn {
font-size: 1.2rem;
padding: 0.25rem 0.5rem;
color: var(--icon-color);
}
.file-card .delete-btn:hover {
color: var(--bs-danger); /* usa el danger contextual, pero ya nombrado */
}

33
src/css/FileUpload.css Normal file
View File

@@ -0,0 +1,33 @@
.upload-card {
border-radius: 12px;
padding: 2rem;
text-align: center;
cursor: pointer;
border: 2px dashed var(--primary-color) !important;
background-color: var(--bg-color) !important;
color: var(--text-color);
}
.upload-card:hover {
border: 2px dashed var(--tertiary-color) !important;
background-color: var(--bg-hover-color) !important;
}
.upload-card.highlight {
border-color: var(--highlight-border) !important;
background-color: var(--bg-hover-color);
}
.upload-card .file-list {
margin-top: 1rem;
text-align: left;
padding: 0;
list-style: none;
}
.upload-card .file-list li {
margin-bottom: 0.5rem;
font-size: 0.9rem;
color: var(--text-color);
}

149
src/css/Footer.css Normal file
View File

@@ -0,0 +1,149 @@
.footer {
background-color: var(--navbar-bg); /* más similar al navbar */
color: var(--fg-color);
border-top: 3px solid var(--border-color);
font-size: 1rem;
box-shadow: 0 -2px 8px var(--shadow-color);
position: relative;
z-index: 1;
}
.footer::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 12px;
background-color: var(--primary-color);
opacity: 0.25;
pointer-events: none;
}
.footer-title,
.footer h6#devd {
font-family: "Product Sans";
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 1rem;
color: var(--primary-color);
}
.footer-columns {
display: flex;
flex-direction: column;
gap: 2rem;
margin-bottom: 2rem;
background-color: var(--bg-hover-color); /* sutil contraste */
padding: 1.5rem;
border-radius: 1rem;
box-shadow: 0 4px 10px rgba(0,0,0,0.04);
}
@media (min-width: 768px) {
.footer-columns {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
}
.footer-column {
flex: 1;
min-width: 200px;
}
.footer-column h5 {
font-size: 1.1rem;
margin-bottom: 0.75rem;
font-weight: 600;
color: var(--fg-color);
}
.footer-column ul {
list-style: none;
padding: 0;
margin: 0;
}
.footer-column ul li {
margin-bottom: 0.5rem;
}
.footer-column ul li a {
color: var(--fg-color);
text-decoration: none;
}
.footer-column ul li a:hover {
color: var(--primary-color);
text-shadow: 0 0 4px currentColor;
}
.footer-bottom {
font-size: 0.9rem;
opacity: 0.85;
text-align: center;
border-top: 1px solid var(--divider-color);
}
.footer-bottom a {
font-weight: 600;
text-decoration: none;
color: var(--fg-color);
}
.footer-bottom a:hover {
text-shadow: 0 0 5px currentColor;
color: var(--primary-color);
}
.contact-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
border-radius: 1rem;
font-size: 0.95rem;
background-color: var(--contact-info-bg);
color: var(--primary-color);
box-shadow: 0 4px 10px var(--shadow-color);
}
.contact-info a {
font-weight: 600;
text-decoration: none;
color: var(--primary-color);
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.contact-info a:hover {
transform: translateX(8px);
color: var(--secondary-color);
}
.contact-info .fa-icon {
font-size: 1.2rem;
color: var(--primary-color);
}
.heart-anim {
display: inline-block;
animation: heartbeat 1.5s infinite ease-in-out;
}
@keyframes heartbeat {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.15);
}
}

42
src/css/Header.css Normal file
View File

@@ -0,0 +1,42 @@
/* ================================
HEADER - ESTILO BASE
================================== */
.bg-img {
background-image: url('/images/bg.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.mask {
background-color: var(--header-mask-color);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.header-title {
font-family: 'Product Sans';
font-size: 3em;
font-weight: bolder;
color: var(--text-color);
}
.shadowed {
text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.5);
}
/* ================================
RESPONSIVE HEADER TITLE
================================== */
@media (max-width: 768px) {
.header-title {
font-size: 2em;
padding: 0 1rem;
text-align: center;
}
}

88
src/css/Home.css Normal file
View File

@@ -0,0 +1,88 @@
/* ================================
ESPACIADO ENTRE SECCIONES
================================== */
.about-section,
.gallery-section,
.map-section {
margin-bottom: 4rem;
}
/* ================================
SECCIÓN - SOBRE NOSOTROS
================================== */
.about-content {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2rem;
}
.text-content {
flex: 1 1 60%;
font-size: 1.1rem;
line-height: 1.6;
color: var(--text-color);
}
.img-content {
flex: 1 1 10%;
}
.about-img {
width: 100%;
border-radius: 10px;
box-shadow: var(--box-shadow-soft);
}
/* ================================
SECCIÓN - GALERÍA
================================== */
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.gallery-img {
width: 100%;
border-radius: 10px;
box-shadow: var(--box-shadow-soft);
}
.gallery-img:hover {
transform: scale(1.05);
}
/* ================================
SECCIÓN - MAPA
================================== */
.map-wrapper iframe {
width: 100%;
height: 60vh;
border: 0;
border-radius: 10px;
box-shadow: var(--box-shadow-soft);
}
/* ================================
RESPONSIVE - MOBILE
================================== */
@media (max-width: 768px) {
.about-content {
flex-direction: column;
}
.img-content {
flex: 1 1 100%;
}
}

45
src/css/IngresoCard.css Normal file
View File

@@ -0,0 +1,45 @@
/* ================================
INGRESO CARD - BASE
================================== */
.ingreso-card {
background-color: var(--card-bg) !important;
color: var(--text-color);
border: none !important;
}
.ingreso-card.from-members {
border: 1px solid var(--card-border) !important;
}
.ingreso-card:hover {
transform: translateY(-5px);
box-shadow: 0 0.75rem 1.5rem var(--shadow-color);
}
.ingreso-card img {
object-fit: cover;
}
/* ================================
CARD HEADER
================================== */
.ingreso-card .card-header button {
border: none;
border-radius: 50%;
padding: 0.5rem;
width: 2.25rem;
height: 2.25rem;
background-color: transparent;
color: var(--card-button) !important;
display: flex;
align-items: center;
justify-content: center;
}
.ingreso-card .card-header button:hover {
background-color: var(--header-btn-hover);
}

0
src/css/Ingresos.css Normal file
View File

16
src/css/List.css Normal file
View File

@@ -0,0 +1,16 @@
.list-group-item {
background-color: var(--item-bg);
color: var(--item-text);
border: none !important;
border-radius: 12px;
}
.list-group-item:hover {
background-color: var(--list-hover-bg) !important;
}
.list-group-item.active {
background-color: var(--list-active-bg) !important;
color: var(--text-color) !important;
}

33
src/css/ListItem.css Normal file
View File

@@ -0,0 +1,33 @@
.custom-list-item {
background-color: var(--item-bg) !important;
color: var(--item-text) !important;
border: none !important;
border-radius: 1rem !important;
}
.custom-list-item:hover {
box-shadow: 0 4px 12px var(--shadow-color);
}
.subtitle {
color: var(--subtitle-color);
}
/* Imagen de perfil */
.list-item-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
margin-right: 15px;
}
/* Índice */
.list-item-index {
font-size: 2rem;
font-weight: bold;
width: 2rem;
text-align: center;
color: var(--primary-color);
}

53
src/css/LoginForm.css Normal file
View File

@@ -0,0 +1,53 @@
/* ================================
LOGIN - CARD CONTAINER (VISUAL)
================================== */
.login-card {
background-color: var(--login-bg) !important;
color: var(--text-color);
box-shadow: 0 0 10px var(--shadow-color);
}
/* ================================
INPUTS VISUALES
================================== */
input.form-control {
background-color: var(--input-bg);
color: var(--input-text);
border: 1px solid var(--input-border);
}
/* ================================
LABELS PERSONALIZADAS
================================== */
.form-floating>label {
font-family: 'Product Sans';
font-size: 1.1em;
color: var(--label-color);
}
.form-floating>label::after {
background-color: transparent !important;
}
/* ================================
BOTÓN VISUAL
================================== */
.login-button {
font-family: 'Product Sans' !important;
font-size: 1.3em !important;
font-weight: bold !important;
background-color: var(--login-btn-bg) !important;
color: var(--login-btn-text) !important;
}
.login-button:hover {
background-color: var(--login-btn-hover) !important;
color: var(--login-btn-text-hover) !important;
}

33
src/css/MailList.css Normal file
View File

@@ -0,0 +1,33 @@
.mail-list {
background-color: var(--card-bg);
height: 100vh;
overflow-y: auto;
padding: 1rem;
flex-grow: 1;
}
.mail-item {
padding: 0.75rem;
border: 1px solid var(--divider-color);
cursor: pointer;
}
.mail-item:hover {
background-color: var(--list-hover-bg);
}
.mail-item.active {
background-color: var(--list-active-bg-light);
}
.mail-item .subject {
font-weight: bold;
color: var(--text-color);
}
.mail-item .preview {
font-size: 0.9rem;
color: var(--muted-color);
margin-top: 0.25rem;
}

View File

@@ -0,0 +1,34 @@
.mail-list-mobile {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.mail-item-mobile {
background-color: var(--card-bg);
border: 1px solid var(--card-border);
padding: 0.75rem 1rem;
border-radius: 12px;
transition: background-color 0.2s ease;
cursor: pointer;
}
.mail-item-mobile:hover {
background-color: var(--card-btn-hover);
}
.mail-item-mobile.active {
border-color: var(--highlight-border);
background-color: var(--list-active-bg-light);
}
.mail-item-mobile .subject {
font-weight: bold;
margin-bottom: 0.25rem;
}
.mail-item-mobile .preview {
font-size: 0.9rem;
color: var(--muted-color);
}

32
src/css/MailToolbar.css Normal file
View File

@@ -0,0 +1,32 @@
.mail-toolbar-wrapper {
position: sticky;
top: 0;
z-index: 999;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background-color: var(--search-bg);
box-shadow: 0 4px 12px var(--shadow-color);
border-bottom: 2px solid var(--search-border);
padding: 0.5rem 1rem;
}
.mail-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
}
.toolbar-icons {
display: flex;
gap: 1rem;
font-size: 1.2rem;
color: var(--toolbar-btn-color);
}
.toolbar-btn {
background: transparent;
border: none;
color: var(--toolbar-btn-color);
font-size: 1.2rem;
}

237
src/css/MailView.css Normal file
View File

@@ -0,0 +1,237 @@
.mail-view {
height: 100%;
flex: 1;
padding: 2rem;
background-color: var(--bg-color);
overflow-y: auto;
}
.mail-view .mail-header {
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--divider-color);
padding-bottom: 1rem;
}
.mail-view .mail-header h2 {
margin: 0;
font-size: 1.5rem;
color: var(--text-color);
}
.mail-view .mail-meta {
color: var(--muted-color);
font-size: 0.9rem;
margin-top: 0.5rem;
}
.mail-view .mail-body {
color: var(--text-color);
font-size: 1rem;
line-height: 1.6;
}
/* Estilos mejorados para contenido HTML rico */
.mail-content {
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
line-height: 1.6;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* Enlaces */
.mail-content a {
color: #0066cc;
text-decoration: underline;
transition: color 0.2s ease;
}
.mail-content a:hover {
color: #004499;
text-decoration: underline;
}
.mail-content a:visited {
color: #551a8b;
}
/* Imágenes */
.mail-content img {
max-width: 100%;
height: auto;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin: 8px 0;
display: block;
}
/* Tablas */
.mail-content table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
background-color: var(--bg-color);
}
.mail-content table th,
.mail-content table td {
padding: 8px 12px;
border: 1px solid var(--divider-color);
text-align: left;
}
.mail-content table th {
background-color: var(--surface-color);
font-weight: 600;
}
/* Listas */
.mail-content ul,
.mail-content ol {
margin: 16px 0;
padding-left: 24px;
}
.mail-content li {
margin: 4px 0;
}
/* Párrafos y espaciado */
.mail-content p {
margin: 16px 0;
}
.mail-content p:first-child {
margin-top: 0;
}
.mail-content p:last-child {
margin-bottom: 0;
}
/* Encabezados */
.mail-content h1,
.mail-content h2,
.mail-content h3,
.mail-content h4,
.mail-content h5,
.mail-content h6 {
color: var(--text-color);
margin: 24px 0 16px 0;
line-height: 1.4;
}
.mail-content h1:first-child,
.mail-content h2:first-child,
.mail-content h3:first-child {
margin-top: 0;
}
/* Citas */
.mail-content blockquote {
margin: 16px 0;
padding: 12px 16px;
border-left: 4px solid var(--primary-color);
background-color: var(--surface-color);
font-style: italic;
border-radius: 0 4px 4px 0;
}
/* Código */
.mail-content code {
background-color: var(--surface-color);
padding: 2px 4px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9em;
}
.mail-content pre {
background-color: var(--surface-color);
padding: 16px;
border-radius: 4px;
overflow-x: auto;
margin: 16px 0;
}
.mail-content pre code {
background: none;
padding: 0;
}
/* Divisores */
.mail-content hr {
border: none;
border-top: 1px solid var(--divider-color);
margin: 24px 0;
}
/* Texto en negrita y cursiva */
.mail-content strong,
.mail-content b {
font-weight: 600;
}
.mail-content em,
.mail-content i {
font-style: italic;
}
/* Adjuntos mejorados */
.mail-attachments {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--divider-color);
}
.mail-attachments h5 {
margin: 0 0 12px 0;
color: var(--text-color);
font-size: 1rem;
font-weight: 600;
}
.mail-attachments ul {
list-style: none;
padding: 0;
margin: 0;
}
.mail-attachments li {
margin: 8px 0;
padding: 8px 12px;
background-color: var(--surface-color);
border-radius: 4px;
border: 1px solid var(--divider-color);
}
.mail-attachments a {
color: var(--text-color);
text-decoration: none;
display: flex;
align-items: center;
}
.mail-attachments a:hover {
color: var(--primary-color);
}
.mail-attachments .attachment-size {
color: var(--muted-color);
font-size: 0.9em;
margin-left: auto;
}
/* Responsivo */
@media (max-width: 768px) {
.mail-view {
padding: 1rem;
}
.mail-content {
font-size: 0.9rem;
}
.mail-content table {
font-size: 0.8rem;
}
}

26
src/css/MobileToolbar.css Normal file
View File

@@ -0,0 +1,26 @@
.mobile-toolbar-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.mobile-toolbar-icons {
display: flex;
gap: 0.75rem;
}
.icon-btn {
background: transparent;
border: none;
color: var(--toolbar-btn-color);
padding: 0.5rem;
font-size: 1.25rem;
border-radius: 50%;
transition: background-color 0.2s ease;
}
.icon-btn:hover {
background-color: var(--toolbar-btn-hover);
}

57
src/css/NavBar.css Normal file
View File

@@ -0,0 +1,57 @@
/* ================================
NAVBAR - VISUAL + THEMING ONLY
================================== */
.navbar {
background-color: var(--navbar-bg) !important;
box-shadow: var(--navbar-shadow);
z-index: 1000;
}
.navbar-brand {
color: var(--navbar-brand-color) !important;
}
.navbar-brand:hover {
color: var(--navbar-brand-hover) !important;
}
a.nav-link,
.nav-item > a.nav-link,
.dropdown-item {
font-family: "Product Sans";
font-size: larger;
border-radius: 8px;
padding: 0.5rem 1rem;
color: var(--navbar-link-color) !important;
}
.nav-link:hover,
.nav-link:focus {
background-color: var(--navbar-link-hover-bg) !important;
color: var(--navbar-link-hover-color) !important;
}
hr {
border-top: 1px solid var(--navbar-divider-color);
}
/* ================================
ANIMACIONES
================================== */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,7 @@
button.show-button {
color: var(--show-btn-color);
}
button.show-button:hover {
color: var(--show-btn-hover);
}

82
src/css/Perfil.css Normal file
View File

@@ -0,0 +1,82 @@
/* ================================
PERFIL CARD - BASE
================================== */
.perfil-card {
background-color: var(--card-bg) !important;
color: var(--text-color);
border: none !important;
}
.perfil-card:hover {
transform: translateY(-3px);
box-shadow: 0 0.5rem 1rem var(--shadow-color);
}
/* ================================
CARD HEADER
================================== */
.perfil-card .card-header {
background-color: var(--secondary-color) !important;
color: var(--card-text-secondary) !important;
border-top-left-radius: 1rem !important;
border-top-right-radius: 1rem !important;
padding: 1rem;
}
.perfil-card .card-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.perfil-card small {
font-style: italic;
color: var(--card-text-secondary);
}
.perfil-card .card-header button {
border: none;
border-radius: 50%;
padding: 0.5rem;
width: 2.25rem;
height: 2.25rem;
background-color: transparent;
color: var(--card-button) !important;
display: flex;
align-items: center;
justify-content: center;
}
.perfil-card .card-header button:hover {
background-color: var(--header-btn-hover);
}
/* ================================
LISTA DE DATOS
================================== */
.perfil-card .list-group {
border: 1px solid var(--card-border) !important;
}
.perfil-card .list-group-item {
font-style: italic;
font-size: 0.95rem;
background-color: transparent;
border-radius: 0;
color: var(--text-color);
border-color: var(--card-border);
padding: 0.75rem 1rem;
}
.perfil-card .list-group-item:hover {
background-color: var(--list-hover-bg);
}
.perfil-card strong {
font-style: normal;
}

46
src/css/Sidebar.css Normal file
View File

@@ -0,0 +1,46 @@
.sidebar {
background-color: var(--sidebar-bg) !important;
border-right: 1px solid var(--divider-color);
padding: 1rem;
height: 100vh;
display: flex;
flex-direction: column;
width: 220px;
flex-shrink: 0;
gap: 1rem;
}
.sidebar .compose-btn {
background-color: var(--btn-bg);
color: var(--btn-text);
border: none;
border-radius: 8px;
padding: 0.5rem 1rem;
font-weight: bold;
cursor: pointer;
}
.sidebar .compose-btn:hover {
background-color: var(--btn-bg-hover);
color: var(--btn-text-hover);
}
.sidebar nav {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sidebar nav a {
padding: 0.5rem 0.75rem;
border-radius: 6px;
color: var(--text-color);
text-decoration: none;
font-weight: 500;
}
.sidebar nav a:hover {
background-color: var(--list-hover-bg);
}

94
src/css/SocioCard.css Normal file
View File

@@ -0,0 +1,94 @@
/* ================================
SOCIO CARD - BASE
================================== */
.socio-card {
background-color: var(--card-bg) !important;
color: var(--text-color);
border: none !important;
}
.socio-card:hover {
transform: translateY(-5px);
box-shadow: 0 0.75rem 1.5rem var(--shadow-color);
}
.socio-card img {
object-fit: cover;
}
/* ================================
CARD HEADER
================================== */
.card-header.bg-light-red {
background-color: var(--alert-bg) !important;
}
.card-header.bg-light-red .card-button {
color: var(--alert-text) !important;
}
.card-header.bg-light-red .card-title {
color: var(--alert-text) !important;
}
.bg-light-green.card-header {
background-color: var(--secondary-color) !important;
}
.card-header.bg-light-green .card-title {
color: var(--card-text-secondary) !important;
}
.socio-card .card-header button {
border: none;
border-radius: 50%;
padding: 0.5rem;
width: 2.25rem;
height: 2.25rem;
background-color: transparent;
color: var(--card-button) !important;
display: flex;
align-items: center;
justify-content: center;
}
.socio-card .card-header button:hover {
background-color: var(--header-btn-hover);
}
/* ================================
LISTA y NOTAS CARD
================================== */
.socio-card .notas-card {
font-size: 0.95rem;
font-style: italic;
background-color: var(--card-bg) !important;
border: 1px solid var(--card-border) !important;
}
.socio-card .list-group {
border: 1px solid var(--card-border) !important;
}
.socio-card .list-group-item {
font-style: italic;
font-size: 0.95rem;
background-color: transparent;
border-radius: 0;
color: var(--text-color);
border-color: var(--card-border);
}
.socio-card .list-group-item:hover {
background-color: var(--list-hover-bg);
}
.socio-card .card-subtitle.h6 {
color: var(--text-color) !important;
}

35
src/css/Socios.css Normal file
View File

@@ -0,0 +1,35 @@
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 1.5rem;
}
@media (max-width: 768px) {
.cards-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
}
.loading-trigger {
margin-top: 2rem;
text-align: center;
}
.modal-content {
background-color: var(--modal-bg);
}
.modal-content .modal-header {
border-bottom: 1px solid var(--modal-header-border);
}
.modal-content .modal-body {
background-color: var(--modal-body-bg);
}
.close-button {
background-color: transparent !important;
color: var(--modal-close-color);
}

83
src/css/SolicitudCard.css Normal file
View File

@@ -0,0 +1,83 @@
/* ================================
SOLICITUD CARD - BASE
================================== */
.solicitud-card {
background-color: var(--card-bg) !important;
color: var(--text-color);
border: none !important;
}
.solicitud-card:hover {
transform: translateY(-5px);
box-shadow: 0 0.75rem 1.5rem var(--shadow-color);
}
.solicitud-card img {
object-fit: cover;
}
.card-header {
background-color: var(--secondary-color) !important;
}
.card-header .card-title {
color: var(--card-text-secondary) !important;
}
.solicitud-card .card-header button {
border: none;
border-radius: 50%;
padding: 0.5rem;
width: 2.25rem;
height: 2.25rem;
background-color: transparent;
color: var(--card-button) !important;
display: flex;
align-items: center;
justify-content: center;
}
.solicitud-card .card-header button:hover {
background-color: var(--header-btn-hover);
}
/* ================================
LISTA Y CONTENIDO
================================== */
.solicitud-card .list-group {
border: 1px solid var(--card-border) !important;
}
.solicitud-card .list-group-item {
font-style: italic;
font-size: 0.95rem;
background-color: transparent;
border-radius: 0;
color: var(--text-color);
border-color: var(--card-border);
}
.solicitud-card .list-group-item:hover {
background-color: var(--list-hover-bg);
}
.solicitud-card .card-subtitle {
font-size: 1rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
color: var(--text-color);
}
/* ================================
ACCIONES (BOTONES)
================================== */
.solicitud-card .btn {
font-weight: 500;
}

61
src/css/ThemeButton.css Normal file
View File

@@ -0,0 +1,61 @@
/* ================================
THEME TOGGLE - BASE
================================== */
.theme-toggle {
width: auto;
height: 40px;
border: none;
border-radius: 999px;
display: flex;
padding: 0 1rem;
align-items: center;
justify-content: center;
cursor: pointer;
outline: none;
background-color: var(--toggle-bg);
color: var(--toggle-fg);
font-family: 'Product Sans';
font-size: 1.2rem;
transition:
background-color 0.3s ease,
color 0.3s ease,
transform 0.2s ease,
box-shadow 0.3s ease;
}
/* ================================
HOVER / ACTIVE STATES
================================== */
.theme-toggle:hover {
transform: scale(1.05);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.2);
}
/* ================================
LIGHT THEME
================================== */
.light {
--toggle-bg: #1e1e1e;
--toggle-fg: #f0f0f0;
}
.light .theme-toggle {
box-shadow: 0 0px 10px rgba(68, 7, 182, 0.808);
}
/* ================================
DARK THEME
================================== */
.dark {
--toggle-bg: #f0f0f0;
--toggle-fg: #1e1e1e;
}
.dark .theme-toggle {
box-shadow: 0 0px 10px rgba(206, 180, 36, 0.589);
}

542
src/css/index.css Normal file
View File

@@ -0,0 +1,542 @@
/* ================================
FUENTES PERSONALIZADAS
================================== */
@font-face {
font-family: "Open Sans";
src: url('/fonts/OpenSans.ttf');
}
@font-face {
font-family: "Product Sans";
src: url('/fonts/ProductSansRegular.ttf');
}
@font-face {
font-family: "Product Sans Italic";
src: url('/fonts/ProductSansItalic.ttf');
}
@font-face {
font-family: "Product Sans Italic Bold";
src: url('/fonts/ProductSansBoldItalic.ttf');
}
@font-face {
font-family: "Product Sans Bold";
src: url('/fonts/ProductSansBold.ttf');
}
/* ================================
PALETA DE COLORES
================================== */
:root {
--highlight-border: var(--accent-color);
--box-shadow-soft: 0 4px 6px var(--shadow-color);
--alert-bg: #f8d7da;
}
/* Tema Claro */
.light {
/* Verdes principales aún más suaves */
--primary-color: #43A748; /* Más apagado que #4CBF50 */
--secondary-color: #187A1C; /* Más terroso que #1B8F1E */
--tertiary-color: #6DC162; /* Verde clarito más natural */
/* Verdes claros de apoyo */
--border-color: #A8E9A0;
--divider-color: #A8E9A0;
/* Fondo, texto y elementos generales */
--bg-color: #E5F1E5; /* Un poco más oscuro que #EDF6ED */
--fg-color: #1F2F1F;
--text-color: #1F2F1F;
--muted-color: #4F6B4F;
--shadow-color: rgba(0, 0, 0, 0.1);
/* Hover y barra de búsqueda */
--bg-hover-color: #D9EAD9;
--bg-search-bar: rgba(67, 167, 72, 0.08);
/* Inputs */
--input-bg: #ffffff;
--input-border: #A8E9A0;
--placeholder-color: #569B56;
--input-text: var(--text-color);
/* Botones y acentos */
--accent-color: var(--primary-color);
--btn-bg: var(--primary-color);
--btn-bg-hover: var(--secondary-color);
--btn-text: #fff;
--btn-text-hover: #fff;
/* Iconos y bordes resaltados */
--icon-color: var(--fg-color);
--highlight-border: var(--tertiary-color);
/* Tarjetas (Cards) */
--card-bg: #ffffff;
--card-button: #fff;
--card-border: #cecece;
--card-text: var(--text-color);
--card-text-secondary: #fff;
--card-btn-hover: rgba(67, 167, 72, 0.06);
--card-muted-text: var(--muted-color);
/* Items, subtítulos */
--item-bg: #ffffff;
--item-text: var(--text-color);
--subtitle-color: var(--muted-color);
/* Login / formularios */
--login-bg: #EAF7EA;
--label-color: var(--text-color);
--login-btn-bg: var(--primary-color);
--login-btn-hover: var(--secondary-color);
--login-btn-text: #fff;
--login-btn-text-hover: var(--text-color);
/* Header */
--header-mask-color: #187A1Caa;
/* Navbar */
--navbar-bg: #ffffff;
--navbar-brand-color: var(--primary-color);
--navbar-brand-hover: var(--secondary-color);
--navbar-link-color: #2e462e;
--navbar-link-hover-bg: #ffffff;
--navbar-link-hover-color: var(--secondary-color);
--navbar-dropdown-bg: #ffffff;
--navbar-dropdown-item-color: var(--text-color);
--navbar-dropdown-item-hover-color: var(--primary-color);
--navbar-divider-color: #A8E9A0;
--hamburger-color: var(--primary-color);
--navbar-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
--show-btn-color: var(--primary-color);
--show-btn-hover: var(--tertiary-color);
--header-btn-hover: rgba(0, 0, 0, 0.05);
/* Listas (hover/active) */
--list-hover-bg: rgba(67, 167, 72, 0.03);
--list-hover-bg-light: #E4F4E4;
--list-active-bg-light: #C3EAC3;
/* Search */
--search-bg: rgba(255, 255, 255, 0.6);
--search-border: #347b36;
--search-input-color: var(--text-color);
--search-placeholder: #569B56;
/* Toolbar */
--toolbar-btn-color: var(--fg-color);
--toolbar-btn-hover: rgba(0, 0, 0, 0.07);
/* Modal */
--modal-bg: #ffffff;
--modal-header-border: #A8E9A0;
--modal-body-bg: #ffffff;
--modal-close-color: var(--text-color);
/* Contacto / secciones de info */
--contact-info-bg: #E5F1E5;
--balance-report-bg: #ffffff;
--file-card-bg: #ffffff;
--sidebar-bg: #f0f0f0;
}
/* Tema Oscuro */
.dark {
/* Colores principales (verdes claros) */
--primary-color: #A9DF58; /* El verde más llamativo */
--secondary-color: #94CD43; /* Verde un pelín más oscuro que el primary */
--tertiary-color: #73B025; /* Otro tono verde de apoyo */
/* Puedes usar #52910A si quieres para el highlight-border o viceversa */
/* Fondos (verdes oscuros) */
--bg-color: #1F2919; /* Fondo principal, el más oscuro */
--bg-hover-color: #283221; /* Hover en elementos */
--bg-search-bar: rgba(31, 41, 25, 0.8);
/* Bordes y sombras */
--border-color: #48523D;
--divider-color: #353F2D;
--shadow-color: rgba(0, 0, 0, 0.35);
/* Texto */
--text-color: #f0f5e6;
--fg-color: #e0ead4;
--muted-color: #a8b398;
/* Inputs */
--input-bg: #2f3428;
--input-border: #606850;
--placeholder-color: #b5c0a0;
/* Botones y acentos */
--accent-color: var(--primary-color);
--btn-bg: var(--primary-color);
--btn-bg-hover: var(--secondary-color);
--btn-text: #1e1e1e;
--btn-text-hover: #fff;
/* Iconos / resaltados */
--icon-color: var(--text-color);
--highlight-border: var(--tertiary-color);
--alert-bg: #4e1c1c;
/* Tarjetas (Cards) */
--card-bg: #283221;
--card-button: #283221;
--card-border: #353F2D;
--card-text: var(--text-color);
--card-text-secondary: #283221;
--card-btn-hover: rgba(255, 255, 255, 0.05);
/* Elementos de lista, items... */
--item-bg: #283221;
--item-text: var(--text-color);
--subtitle-color: var(--muted-color);
/* Login / formularios */
--login-bg: #283221;
--input-text: var(--text-color);
--label-color: var(--text-color);
--login-btn-bg: var(--primary-color);
--login-btn-hover: var(--secondary-color);
--login-btn-text: #222;
--login-btn-text-hover: #fff;
/* Header */
--header-mask-color: #44732980;
/* Navbar */
--navbar-bg: #283221;
--navbar-brand-color: var(--primary-color);
--navbar-brand-hover: var(--secondary-color);
--navbar-link-color: #d8e8c0;
--navbar-link-hover-bg: #283221;
--navbar-link-hover-color: var(--secondary-color);
--navbar-dropdown-bg: #283221;
--navbar-dropdown-item-color: #f0f0f0;
--navbar-dropdown-item-hover-color: #3e4434;
--navbar-divider-color: #444;
--hamburger-color: var(--primary-color);
--navbar-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
/* Otros elementos */
--show-btn-color: var(--primary-color);
--show-btn-hover: var(--tertiary-color);
--card-muted-text: var(--muted-color);
--header-btn-hover: rgba(255, 255, 255, 0.05);
--list-hover-bg: rgba(255, 255, 255, 0.03);
--list-hover-bg-dark: #404833;
--list-active-bg-dark: #4f5a3b;
--search-bg: #1f2919b3;
--search-border: #697b54; /* Otro verde más intenso */
--search-input-color: #f0f0f0;
--search-placeholder: #c0d0b0;
--toolbar-btn-color: #dbe5cb;
--toolbar-btn-hover: rgba(255, 255, 255, 0.08);
--modal-bg: #283221;
--modal-header-border: #353F2D;
--modal-body-bg: #283221;
--modal-close-color: #fff;
--contact-info-bg: #1F2919;
--balance-report-bg: #283221;
--file-card-bg: #283221;
--sidebar-bg: #171d13;
}
/* ================================
ESTILOS BASE / RESET SUAVE
================================== */
html,
body {
font-family: "Open Sans", sans-serif;
color: var(--text-color);
background-color: var(--bg-color);
}
body {
background-color: transparent !important; /* compatibilidad navbar fija */
}
main {
color: var(--text-color);
background-color: var(--bg-color);
}
/* Tipografía global */
div,
label,
input,
p,
span,
a,
button {
font-family: "Open Sans", sans-serif;
color: var(--text-color);
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Product Sans", sans-serif;
color: var(--text-color);
}
/* Inputs */
input,
textarea,
select {
background-color: var(--input-bg) !important;
color: var(--text-color) !important;
}
input::placeholder,
textarea::placeholder {
color: var(--placeholder-color) !important;
}
input.form-control ::after {
color: var(--placeholder-color) !important;
}
/* Enfoque de campos */
textarea:focus,
input[type="text"]:focus,
input[type="password"]:focus,
input[type="datetime"]:focus,
input[type="datetime-local"]:focus,
input[type="date"]:focus,
input[type="month"]:focus,
input[type="time"]:focus,
input[type="week"]:focus,
input[type="number"]:focus,
input[type="email"]:focus,
input[type="url"]:focus,
input[type="search"]:focus,
input[type="tel"]:focus,
input[type="color"]:focus,
.uneditable-input:focus {
border-color: var(--accent-color) !important;
box-shadow:
0 0 0px rgba(0, 0, 0, 0.075) inset,
0 0 0px var(--accent-color) !important;
outline: 0 none !important;
}
/* Títulos y separadores */
.section-title {
font-family: 'Product Sans', sans-serif;
font-size: 2.5rem;
font-weight: 600;
margin-bottom: 1rem;
}
.section-divider {
border: none;
height: 2px;
margin: 1rem 0 2rem;
width: 50px;
background-color: var(--text-color);
}
/* ===================
SEARCH TOOLBAR
=================== */
.search-toolbar-wrapper {
position: sticky;
top: 64px;
z-index: 900;
margin-bottom: 2rem;
}
.search-toolbar {
display: flex;
align-items: center;
width: 100%;
border-radius: 999px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: 0.5rem 1rem;
background-color: var(--search-bg);
border: 2px solid var(--search-border) !important;
box-shadow: 0 4px 12px var(--shadow-color);
}
/* Cuando el input está enfocado */
.search-toolbar:has(input:focus) {
transform: scale(1.02);
box-shadow: 0 6px 16px var(--shadow-color);
border-color: var(--accent-color) !important;
}
/* Fallback si :has no es compatible */
.search-toolbar.focused {
transform: scale(1.02);
box-shadow: 0 6px 16px var(--shadow-color);
border-color: var(--accent-color) !important;
}
.search-toolbar input.search-input {
all: unset;
flex-grow: 1;
width: 100%;
height: 32px;
font-size: 1.1rem;
font-family: "Open Sans", sans-serif;
padding-right: 1rem;
background: transparent !important;
color: var(--search-input-color);
}
.search-toolbar input.search-input::placeholder {
color: var(--search-placeholder);
}
.toolbar-buttons {
display: flex;
gap: 0.5rem;
}
.toolbar-buttons .btn {
background: transparent;
border: none;
color: var(--toolbar-btn-color);
padding: 0.25rem 0.5rem;
border-radius: 50%;
}
.toolbar-buttons .btn:hover {
background-color: var(--toolbar-btn-hover);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 2px solid var(--modal-header-border);
background-color: var(--modal-bg);
color: var(--text-color);
}
.modal-body {
background-color: transparent;
}
.modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border: none;
background-color: var(--modal-bg);
}
.modal-dialog,.modal-content {
background-color: transparent !important;
}
.form-control ~ label {
color: var(--text-color) !important;
}
.form-control:focus ~ label {
color: var(--text-color) !important;
}
.card-header span:not(.badge) {
font-size: 1.1rem;
}
.card-header span:not(.badge),small:not(.dates-small) {
color: var(--card-text-secondary) !important;
}
.custom-toggler svg {
width: 30px;
height: 30px;
}
button.custom-toggler {
background: transparent !important;
border: none !important;
color: var(--secondary-color) !important;
font-size: 1.5rem !important;
}
.themed-input {
background-color: var(--input-bg) !important;
color: var(--input-text) !important;
border: 1px solid var(--input-border) !important;
border-radius: 8px;
padding: 0.25rem 0.5rem;
font-size: 0.9rem;
}
.themed-input:focus {
border-color: var(--accent-color) !important;
box-shadow: 0 0 0 0.15rem rgba(67, 167, 72, 0.25);
outline: none;
}
.themed-input::placeholder {
color: var(--placeholder-color) !important;
}
.dates {
font-size: 0.8rem;
color: var(--subtitle-color) !important;
font-style: italic;
margin-top: -0.5rem;
}
.icon-with-badge {
position: absolute;
display: inline-block;
}
.icon-badge {
position: absolute;
top: -6px;
right: -10px;
background: rgb(229, 26, 36);
color: white;
font-size: 0.8rem;
padding: 2px 5px;
border-radius: 50%;
font-weight: bold;
line-height: 1;
}
.light .card-header span.badge.bg-success {
background-color: var(--primary-color) !important;
color: white !important;
}
.disabled {
pointer-events: none;
opacity: 0.6;
}

4
src/hooks/useAuth.js Normal file
View File

@@ -0,0 +1,4 @@
import { useContext } from "react";
import { AuthContext } from "../context/AuthContext";
export const useAuth = () => useContext(AuthContext);

4
src/hooks/useConfig.js Normal file
View File

@@ -0,0 +1,4 @@
import { useContext } from "react";
import { ConfigContext } from "../context/ConfigContext.jsx";
export const useConfig = () => useContext(ConfigContext);

130
src/hooks/useData.js Normal file
View File

@@ -0,0 +1,130 @@
import { useState, useEffect, useCallback, useRef } from "react";
import axios from "axios";
export const useData = (config) => {
const [data, setData] = useState(null);
const [dataLoading, setLoading] = useState(true);
const [dataError, setError] = useState(null);
const configRef = useRef();
useEffect(() => {
if (config?.baseUrl) {
configRef.current = config;
}
}, [config]);
const getAuthHeaders = () => ({
"Content-Type": "application/json",
"Authorization": `Bearer ${localStorage.getItem("token")}`,
});
const fetchData = useCallback(async () => {
const current = configRef.current;
if (!current?.baseUrl) return;
setLoading(true);
setError(null);
try {
const response = await axios.get(current.baseUrl, {
headers: getAuthHeaders(),
params: current.params,
});
setData(response.data.data);
} catch (err) {
setError(err.response?.data?.message || err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (config?.baseUrl) {
fetchData();
}
}, [config, fetchData]);
const getData = async (url, params = {}) => {
try {
const response = await axios.get(url, {
headers: getAuthHeaders(),
params,
});
return { data: response.data.data, error: null };
} catch (err) {
return {
data: null,
error: err.response?.data?.message || err.message,
};
}
};
const postData = async (endpoint, payload) => {
const headers = {
Authorization: `Bearer ${localStorage.getItem("token")}`,
...(payload instanceof FormData ? {} : { "Content-Type": "application/json" }),
};
const response = await axios.post(endpoint, payload, { headers });
await fetchData();
return response.data.data;
};
const postDataValidated = async (endpoint, payload) => {
try {
const headers = {
Authorization: `Bearer ${localStorage.getItem("token")}`,
...(payload instanceof FormData ? {} : { "Content-Type": "application/json" }),
};
const response = await axios.post(endpoint, payload, { headers });
return { data: response.data.data, errors: null };
} catch (err) {
const raw = err.response?.data?.message;
let parsed = {};
try {
parsed = JSON.parse(raw);
} catch {
return { data: null, errors: { general: raw || err.message } };
}
return { data: null, errors: parsed };
}
};
const putData = async (endpoint, payload) => {
const response = await axios.put(endpoint, payload, {
headers: getAuthHeaders(),
});
await fetchData();
return response.data.data;
};
const deleteData = async (endpoint) => {
const response = await axios.delete(endpoint, {
headers: getAuthHeaders(),
});
await fetchData();
return response.data.data;
};
const deleteDataWithBody = async (endpoint, payload) => {
const response = await axios.delete(endpoint, {
headers: getAuthHeaders(),
data: payload,
});
await fetchData();
return response.data.data;
};
return {
data,
dataLoading,
dataError,
getData,
postData,
postDataValidated,
putData,
deleteData,
deleteDataWithBody,
};
};

View File

@@ -0,0 +1,4 @@
import { useContext } from "react";
import { DataContext } from "../context/DataContext";
export const useDataContext = () => useContext(DataContext);

View File

@@ -0,0 +1,48 @@
import { useState, useRef, useMemo } from 'react';
export const usePaginatedList = ({
data,
pageSize = 10,
filterFn = () => true,
searchFn = () => true,
sortFn = null,
initialFilters = {}
}) => {
const [searchTerm, setSearchTerm] = useState("");
const [filters, setFilters] = useState(initialFilters);
const [creatingItem, setCreatingItem] = useState(false);
const [tempItem, setTempItem] = useState(null);
const isSearching = searchTerm.trim() !== "";
const isFiltering = Object.keys(filters).some(k => filters[k] === false);
const usingSearchOrFilters = isSearching || isFiltering;
const filteredData = useMemo(() => {
if (!data) return [];
let result = data
.filter((item) => filterFn(item, filters))
.filter((item) => searchFn(item, searchTerm));
if (sortFn) {
result = [...result].sort(sortFn); // 👈 Ordena si hay sortFn
}
return result;
}, [data, filterFn, filters, searchFn, searchTerm, sortFn]);
return {
paginated: filteredData.slice(0, pageSize),
filtered: filteredData,
searchTerm,
setSearchTerm,
filters,
setFilters,
loaderRef: useRef(), // opcional si tu PaginatedCardGrid lo espera
loading: false,
hasMore: false,
creatingItem,
setCreatingItem,
tempItem,
setTempItem,
isUsingFilters: usingSearchOrFilters,
resetPagination: () => { } // ya no es necesario pero por compat
};
};

View File

@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';
import axios from 'axios';
import { useConfig } from './useConfig';
const useRequestCount = () => {
const { config } = useConfig();
const [count, setCount] = useState(null);
useEffect(() => {
if (!config) return;
const fetchCount = async () => {
try {
const res = await axios.get(
config.apiConfig.baseUrl + config.apiConfig.endpoints.requests.countPending,
{
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
}
);
setCount(res.data.data.count);
} catch (err) {
console.error('❌ Error al obtener el número de solicitudes:', err.message);
}
};
fetchCount();
}, [config]);
return count;
};
export default useRequestCount;

View File

@@ -0,0 +1,103 @@
import { useEffect, useState } from "react";
import { parseJwt } from "../util/tokenUtils.js";
import NotificationModal from "../components/NotificationModal.jsx";
import axios from "axios";
import { useAuth } from "./useAuth.js";
import { useConfig } from "./useConfig.js";
const useSessionRenewal = () => {
const { logout } = useAuth();
const { config } = useConfig();
const [showModal, setShowModal] = useState(false);
const [alreadyWarned, setAlreadyWarned] = useState(false);
useEffect(() => {
const interval = setInterval(() => {
const token = localStorage.getItem("token");
const decoded = parseJwt(token);
if (!token || !decoded?.exp) return;
const now = Date.now();
const expTime = decoded.exp * 1000;
const timeLeft = expTime - now;
if (timeLeft <= 60000 && timeLeft > 0 && !alreadyWarned) {
setShowModal(true);
setAlreadyWarned(true);
}
if (timeLeft <= 0) {
clearInterval(interval);
logout();
}
}, 10000); // revisa cada 10 segundos
return () => clearInterval(interval);
}, [alreadyWarned, logout]);
const handleRenew = async () => {
const token = localStorage.getItem("token");
const decoded = parseJwt(token);
const now = Date.now();
const expTime = decoded?.exp * 1000;
if (!token || !decoded || now > expTime) {
logout();
return;
}
try {
const response = await axios.get(
`${config.apiConfig.baseUrl}${config.apiConfig.endpoints.auth.refreshToken}`,
null,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
const newToken = response.data.data.token;
localStorage.setItem("token", newToken);
setShowModal(false);
setAlreadyWarned(false);
} catch (err) {
console.error("Error renovando sesión:", err);
logout();
}
};
const modal = showModal && (
<NotificationModal
show={true}
onClose={() => {
setShowModal(false);
logout();
}}
title="¿Quieres seguir conectado?"
message="Tu sesión está a punto de expirar. ¿Quieres renovarla 1 hora más?"
variant="info"
buttons={[
{
label: "Renovar sesión",
variant: "success",
onClick: handleRenew,
},
{
label: "Cerrar sesión",
variant: "danger",
onClick: () => {
logout();
setShowModal(false);
},
},
]}
/>
);
return { modal };
};
export default useSessionRenewal;

10
src/hooks/useTheme.js Normal file
View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
import { ThemeContext } from "../context/ThemeContext";
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme debe usarse dentro de un <ThemeProvider>");
}
return context;
};

31
src/main.jsx Normal file
View File

@@ -0,0 +1,31 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
/* COMPONENTS */
import App from './components/App.jsx'
import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider } from './context/ThemeContext'
import { AuthProvider } from './context/AuthContext'
import { ConfigProvider } from './context/ConfigContext.jsx'
/* CSS */
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
import './css/index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<ConfigProvider>
<ThemeProvider>
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</ThemeProvider>
</ConfigProvider>
</StrictMode>
)

207
src/pages/Anuncios.jsx Normal file
View File

@@ -0,0 +1,207 @@
import { useState } from 'react';
import { useConfig } from '../hooks/useConfig';
import { DataProvider } from '../context/DataContext';
import { useDataContext } from '../hooks/useDataContext';
import { usePaginatedList } from '../hooks/usePaginatedList';
import CustomContainer from '../components/CustomContainer';
import ContentWrapper from '../components/ContentWrapper';
import LoadingIcon from '../components/LoadingIcon';
import SearchToolbar from '../components/SearchToolbar';
import PaginatedCardGrid from '../components/PaginatedCardGrid';
import AnuncioCard from '../components/Anuncios/AnuncioCard';
import AnunciosFilter from '../components/Anuncios/AnunciosFilter';
import { errorParser } from '../util/parsers/errorParser';
import CustomModal from '../components/CustomModal';
import { Button } from 'react-bootstrap';
import { EditorProvider } from 'react-simple-wysiwyg';
const PAGE_SIZE = 10;
const Anuncios = () => {
const { config, configLoading } = useConfig();
if (configLoading) return <p><LoadingIcon /></p>;
const reqConfig = {
baseUrl: `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.announces.all}`,
params: {
_sort: 'created_at',
_order: 'desc',
},
};
return (
<DataProvider config={reqConfig}>
<AnunciosContent reqConfig={reqConfig} />
</DataProvider>
);
};
const AnunciosContent = ({ reqConfig }) => {
const { data, dataLoading, dataError, postData, putData, deleteData } = useDataContext();
const [creatingAnuncio, setCreatingAnuncio] = useState(false);
const [tempAnuncio, setTempAnuncio] = useState(null);
const [error, setError] = useState(null);
const [deleteTargetId, setDeleteTargetId] = useState(null);
const {
filtered,
searchTerm,
setSearchTerm,
filters,
setFilters,
} = usePaginatedList({
data,
pageSize: PAGE_SIZE,
filterFn: (anuncio, filters) => {
if (filters.todos) return true;
const matchesPrioridad =
(filters.baja && anuncio.priority === 0) ||
(filters.media && anuncio.priority === 1) ||
(filters.alta && anuncio.priority === 2);
const createdAt = new Date(anuncio.created_at);
const now = new Date();
const matchesFecha =
(filters.ultimos7 && (now - createdAt) / (1000 * 60 * 60 * 24) <= 7) ||
(filters.esteMes &&
createdAt.getMonth() === now.getMonth() &&
createdAt.getFullYear() === now.getFullYear());
return matchesPrioridad || matchesFecha;
},
searchFn: (anuncio, term) => {
const normalized = term.toLowerCase();
return (
anuncio.body?.toLowerCase().includes(normalized) ||
anuncio.published_by_name?.toLowerCase().includes(normalized)
);
},
initialFilters: {
todos: true,
baja: true,
media: true,
alta: true,
ultimos7: true,
esteMes: true,
},
});
const handleCreate = () => {
setCreatingAnuncio(true);
setTempAnuncio({
announce_id: null,
body: 'Nuevo anuncio',
priority: 1,
published_by_name: 'Admin',
});
document.querySelector('.cards-grid')?.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleCancelCreate = () => {
setCreatingAnuncio(false);
setTempAnuncio(null);
};
const handleCreateSubmit = async (nuevo) => {
try {
await postData(reqConfig.baseUrl, nuevo);
setError(null);
setCreatingAnuncio(false);
setTempAnuncio(null);
} catch (err) {
setTempAnuncio({ ...nuevo });
setError(errorParser(err));
}
};
const handleEditSubmit = async (editado, id) => {
try {
await putData(`${reqConfig.baseUrl}/${id}`, editado);
setError(null);
} catch (err) {
setError(errorParser(err));
}
};
const handleDelete = async (id) => {
setDeleteTargetId(id);
};
if (dataLoading) return <p className="text-center my-5"><LoadingIcon /></p>;
if (dataError) return <p className="text-danger text-center my-5">{dataError}</p>;
return (
<CustomContainer>
<ContentWrapper>
<div className="d-flex justify-content-between align-items-center m-0 p-0">
<h1 className='section-title'>Tablón de Anuncios</h1>
</div>
<hr className="section-divider" />
<SearchToolbar
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
filtersComponent={<AnunciosFilter filters={filters} onChange={setFilters} />}
onCreate={handleCreate}
/>
<PaginatedCardGrid
items={filtered}
creatingItem={creatingAnuncio}
renderCreatingCard={() => (
<EditorProvider>
<AnuncioCard
anuncio={tempAnuncio}
isNew
onCreate={handleCreateSubmit}
onCancel={handleCancelCreate}
error={error}
onClearError={() => setError(null)}
/>
</EditorProvider>
)}
renderCard={(anuncio) => (
<AnuncioCard
key={anuncio.announce_id}
anuncio={anuncio}
onUpdate={(a, id) => handleEditSubmit(a, id)}
onDelete={() => handleDelete(anuncio.announce_id)}
error={error}
onClearError={() => setError(null)}
/>
)}
/>
<CustomModal
title="Confirmar eliminación"
show={deleteTargetId !== null}
onClose={() => setDeleteTargetId(null)}
>
<p className='p-3'>¿Estás seguro de que quieres eliminar el anuncio?</p>
<div className="d-flex justify-content-end gap-2 mt-3 p-3">
<Button variant="secondary" onClick={() => setDeleteTargetId(null)}>Cancelar</Button>
<Button
variant="danger"
onClick={async () => {
try {
await deleteData(`${reqConfig.baseUrl}/${deleteTargetId}`);
setSearchTerm("");
setDeleteTargetId(null);
} catch (err) {
setError(errorParser(err));
}
}}
>
Confirmar
</Button>
</div>
</CustomModal>
</ContentWrapper>
</CustomContainer>
);
};
export default Anuncios;

44
src/pages/Balance.jsx Normal file
View File

@@ -0,0 +1,44 @@
import { useConfig } from '../hooks/useConfig';
import { DataProvider } from '../context/DataContext';
import { useDataContext } from '../hooks/useDataContext';
import CustomContainer from '../components/CustomContainer';
import ContentWrapper from '../components/ContentWrapper';
import LoadingIcon from '../components/LoadingIcon';
import BalanceReport from '../components/Balance/BalanceReport';
const Balance = () => {
const { config, configLoading } = useConfig();
if (configLoading || !config) return <p className="text-center my-5"><LoadingIcon /></p>;
const reqConfig = {
baseUrl: config.apiConfig.baseUrl + "/v1/balance/with-totals"
};
return (
<DataProvider config={reqConfig}>
<BalanceContent />
</DataProvider>
);
};
const BalanceContent = () => {
const { data, dataLoading, dataError } = useDataContext();
if (dataLoading) return <p className="text-center my-5"><LoadingIcon /></p>;
if (dataError) return <p className="text-danger text-center my-5">{dataError}</p>;
if (!data || !data.id) return <p className="text-center my-5">No se encontró el balance.</p>;
return (
<CustomContainer>
<ContentWrapper>
<h1 className="section-title">Resumen del Balance</h1>
<hr className="section-divider" />
<BalanceReport balance={data} />
</ContentWrapper>
</CustomContainer>
);
};
export default Balance;

Some files were not shown because too many files have changed in this diff Show More