created: monorepo with front & back

This commit is contained in:
Jose
2026-03-07 00:00:09 +01:00
commit 76b30df1ba
272 changed files with 23425 additions and 0 deletions

View File

@@ -0,0 +1,303 @@
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 { 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,
dropdown = new Map(),
fieldErrors
}) => {
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,
memberNumber: income.memberNumber,
userId: income.userId,
displayName: income.displayName || '',
createdAt: income.createdAt || (isNew ? getNowAsLocalDatetime() : ''),
});
const getFieldError = (field) => fieldErrors?.[field] ?? null;
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,
userId: income.userId,
memberNumber: income.memberNumber,
displayName: income.displayName || '',
createdAt: income.createdAt || (isNew ? getNowAsLocalDatetime() : ''),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [income, editMode]);
const handleChange = (field, value) =>
setFormData(prev => ({ ...prev, [field]: value }));
const handleCancel = () => {
if (isNew && typeof onCancel === 'function') return onCancel();
setEditMode(false);
};
const handleSave = () => {
const newIncome = { ...income, ...formData };
if (createMode && typeof onCreate === 'function') return onCreate(newIncome);
if (typeof onUpdate === 'function') return onUpdate(newIncome, income.incomeId);
};
const handleDelete = () => typeof onDelete === 'function' && onDelete(income.incomeId);
const uniqueMembers = Array.from(dropdown.entries())
.map(([memberNumber, member]) => ({
memberNumber,
...member
}))
.sort((a, b) => a.memberNumber - b.memberNumber);
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}
isInvalid={!!fieldErrors?.concept}
onChange={(e) => handleChange('concept', e.target.value.toUpperCase())}
/>
<Form.Control.Feedback type="invalid" as="span">
{getFieldError("concept")}
</Form.Control.Feedback>
</>
) : formData.concept}
</span>
<small>
{editMode ? (
<SpanishDateTimePicker
selected={new Date(formData.createdAt)}
onChange={(date) =>
handleChange('createdAt', date.toISOString())
}
/>
) : (
DateParser.isoToStringWithTime(formData.createdAt)
)}
</small>
</div>
</div>
{editable && !createMode && !editMode && (
<AnimatedDropdown
className='end-0'
icon={<FontAwesomeIcon icon={faEllipsisVertical} className="fa-xl" />}
>
{({ 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>
)}
</div>
</Card.Header>
<Card.Body>
<Card.Text className="mb-2">
<FontAwesomeIcon icon={faUser} className="me-2" />
<strong>Socio:</strong>{' '}
{createMode ? (
<Form.Select
className="themed-input"
size="sm"
value={formData.memberNumber ?? ""}
onChange={(e) => {
const memberNumber = parseInt(e.target.value, 10);
const member = dropdown.get(memberNumber);
handleChange('memberNumber', memberNumber);
handleChange('userId', member?.userId ?? null);
handleChange('displayName', member?.displayName ?? "");
}}
>
<option value="" disabled>Selecciona socio</option>
{uniqueMembers.map((i) => (
<option key={i.memberNumber} value={i.memberNumber}>
{`${i.displayName} (${i.memberNumber})`}
</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.displayName || 'Socio'} (${formData.memberNumber})`}
style={{ maxWidth: '300px', display: 'inline-block' }}
/>
</OverlayTrigger>
) : (
formData.displayName ? (
<>
<OverlayTrigger
placement="top"
overlay={<Tooltip>{formData.displayName}</Tooltip>}
>
<span className="text-truncate d-inline-block" style={{ maxWidth: '200px', verticalAlign: 'middle' }}>
{formData.displayName}
</span>
</OverlayTrigger>
&nbsp;({formData.memberNumber})
</>
) : formData.memberNumber
)}
</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"
isInvalid={!!fieldErrors?.amount}
value={formData.amount}
onChange={(e) => handleChange('amount', parseFloat(e.target.value))}
style={{ maxWidth: '150px', display: 'inline-block' }}
/>
<Form.Control.Feedback type="invalid" as="span">
{getFieldError("amount")}
</Form.Control.Feedback>
</>
) : `${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,
members: PropTypes.array,
fieldErrors: PropTypes.object
};
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.memberNumber}</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.createdAt)}</Text>
</View>
))}
</Page>
</Document>
);