Compare commits

..

No commits in common. "67b11504a75f52a62f2c58bc947a0ab5281d1010" and "c81a1ea5882cb9aa8595097025da173c586e4c9d" have entirely different histories.

11 changed files with 61 additions and 2096 deletions

1
.gitignore vendored
View File

@ -1 +0,0 @@
/docker/mysql-data/

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,7 @@ services:
ports: ports:
- "3307:3306" - "3307:3306"
volumes: volumes:
- ./mysql-data:/var/lib/mysql - mysql_data:/var/lib/mysql
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p1234"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p1234"]
interval: 10s interval: 10s
@ -26,8 +26,7 @@ services:
dockerfile: ../docker/Dockerfile-api dockerfile: ../docker/Dockerfile-api
container_name: memberflow-backend container_name: memberflow-backend
depends_on: depends_on:
mysql: - mysql
condition: service_healthy
ports: ports:
- "8080:8080" - "8080:8080"
environment: environment:
@ -38,7 +37,6 @@ services:
JWT_EXPIRATION: 7200000 JWT_EXPIRATION: 7200000
frontend: frontend:
build: build:
context: .. context: ..
@ -48,3 +46,6 @@ services:
- backend - backend
ports: ports:
- "3000:80" - "3000:80"
volumes:
mysql_data:

View File

@ -14,8 +14,6 @@ public class ProductServiceDTO {
@NotNull @NotNull
private Integer ivaTypeId; private Integer ivaTypeId;
private IVATypeDTO ivaType;
@NotNull @NotNull
private String name; private String name;
@ -30,13 +28,11 @@ public class ProductServiceDTO {
@NotNull @NotNull
private StatusValues status; private StatusValues status;
public ProductServiceDTO() { public ProductServiceDTO() {}
}
public ProductServiceDTO(ProductService entity) { public ProductServiceDTO(ProductService entity) {
this.id = entity.getId(); this.id = entity.getId();
this.ivaTypeId = entity.getIvaType() != null ? entity.getIvaType().getId() : null; this.ivaTypeId = entity.getIvaType() != null ? entity.getIvaType().getId() : null;
this.ivaType = entity.getIvaType() != null ? new IVATypeDTO(entity.getIvaType()) : null;
this.name = entity.getName(); this.name = entity.getName();
this.description = entity.getDescription(); this.description = entity.getDescription();
this.price = entity.getPrice(); this.price = entity.getPrice();
@ -70,7 +66,9 @@ public class ProductServiceDTO {
return entity; return entity;
} }
// Getters y setters
// Getters y setters...
public Integer getId() { public Integer getId() {
return id; return id;
@ -88,14 +86,6 @@ public class ProductServiceDTO {
this.ivaTypeId = ivaTypeId; this.ivaTypeId = ivaTypeId;
} }
public IVATypeDTO getIvaType() {
return ivaType;
}
public void setIvaType(IVATypeDTO ivaType) {
this.ivaType = ivaType;
}
public String getName() { public String getName() {
return name; return name;
} }

View File

@ -131,23 +131,10 @@ public class InvoiceService extends AbstractService<Invoice, Integer> {
} }
public void updateInvoiceTotal(Invoice invoice) { public void updateInvoiceTotal(Invoice invoice) {
BigDecimal total = BigDecimal.ZERO; BigDecimal total = invoice.getInvoiceLines().stream()
.map(InvoiceLine::getSubtotal)
for (InvoiceLine line : invoice.getInvoiceLines()) { .filter(Objects::nonNull)
if (line.getUnitPrice() != null && line.getQuantity() != null) { .reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal quantity = BigDecimal.valueOf(line.getQuantity());
BigDecimal base = line.getUnitPrice().multiply(quantity);
BigDecimal ivaMultiplier = BigDecimal.ONE;
if (line.getProductService() != null && line.getProductService().getIvaType() != null) {
BigDecimal iva = line.getProductService().getIvaType().getPercentage();
ivaMultiplier = ivaMultiplier.add(iva.divide(BigDecimal.valueOf(100)));
}
BigDecimal lineTotal = base.multiply(ivaMultiplier);
total = total.add(lineTotal);
}
}
invoice.setTotal(total); invoice.setTotal(total);
} }

View File

@ -1,118 +0,0 @@
import React, { useEffect, useState } from "react";
import api from "../../api/axiosConfig";
const IVATypeManager = () => {
const [ivaTypes, setIvaTypes] = useState([]);
const [newIva, setNewIva] = useState({ percentage: "", description: "" });
const [error, setError] = useState("");
const fetchIvaTypes = async () => {
try {
const res = await api.get("/iva-types/getAll");
setIvaTypes(res.data);
} catch (err) {
console.error(err);
setError("Error al obtener los tipos de IVA.");
}
};
useEffect(() => {
fetchIvaTypes();
}, []);
const handleChange = (e) => {
const { name, value } = e.target;
setNewIva((prev) => ({ ...prev, [name]: value }));
};
const handleAddIva = async () => {
setError("");
if (!newIva.percentage || isNaN(newIva.percentage)) {
setError("Introduce un porcentaje válido.");
return;
}
try {
const payload = {
percentage: parseFloat(newIva.percentage),
description: newIva.description
};
await api.post("/iva-types/create", payload);
setNewIva({ percentage: "", description: "" });
fetchIvaTypes();
} catch (err) {
console.error(err);
setError("No se pudo crear el tipo de IVA.");
}
};
const handleDelete = async (id) => {
if (!window.confirm("¿Seguro que deseas eliminar este tipo de IVA?")) return;
try {
await api.delete(`/iva-types/deleteById/${id}`);
fetchIvaTypes();
} catch (err) {
console.error(err);
setError("No se pudo eliminar el tipo de IVA.");
}
};
return (
<div className="content-area">
<h2>Gestión de Tipos de IVA</h2>
{error && <p style={{ color: "red" }}>{error}</p>}
<div className="form-section">
<label>Porcentaje:</label>
<input
type="number"
name="percentage"
value={newIva.percentage}
onChange={handleChange}
step="0.01"
/>
<label>Descripción:</label>
<input
type="text"
name="description"
value={newIva.description}
onChange={handleChange}
/>
<button className="btn btn-primary" onClick={handleAddIva}>
+ Añadir IVA
</button>
</div>
<table className="table" style={{ marginTop: "2rem" }}>
<thead>
<tr>
<th>ID</th>
<th>Porcentaje</th>
<th>Descripción</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{ivaTypes.map((iva) => (
<tr key={iva.id}>
<td>{iva.id}</td>
<td>{iva.percentage}%</td>
<td>{iva.description}</td>
<td>
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(iva.id)}>
🗑 Eliminar
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default IVATypeManager;

View File

@ -10,7 +10,7 @@ const InvoiceForm = () => {
const [lines, setLines] = useState([]); const [lines, setLines] = useState([]);
const [products, setProducts] = useState([]); const [products, setProducts] = useState([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [createdInvoice, setCreatedInvoice] = useState(null); const [createdInvoiceId, setCreatedInvoiceId] = useState(null);
useEffect(() => { useEffect(() => {
api.get("/students/getAll").then((res) => setStudents(res.data)); api.get("/students/getAll").then((res) => setStudents(res.data));
@ -31,31 +31,19 @@ const InvoiceForm = () => {
setLines(lines.filter((_, i) => i !== index)); setLines(lines.filter((_, i) => i !== index));
}; };
const calculateSubtotal = () => {
return lines.reduce((acc, line) => {
const product = products.find((p) => p.id === parseInt(line.productServiceId));
if (!product) return acc;
return acc + product.price * (parseInt(line.quantity) || 1);
}, 0);
};
const calculateIVA = () => {
return lines.reduce((acc, line) => {
const product = products.find((p) => p.id === parseInt(line.productServiceId));
if (!product || !product.ivaType) return acc;
const quantity = parseInt(line.quantity) || 1;
const iva = product.ivaType.percentage || 0;
return acc + (product.price * quantity * (iva / 100));
}, 0);
};
const calculateTotal = () => { const calculateTotal = () => {
return calculateSubtotal() + calculateIVA(); return lines.reduce((acc, line) => {
const product = products.find(
(p) => p.id === parseInt(line.productServiceId)
);
if (!product) return acc;
return acc + product.price * line.quantity;
}, 0);
}; };
const createInvoice = async () => { const createInvoice = async () => {
setError(""); setError("");
setCreatedInvoice(null); setCreatedInvoiceId(null);
if (!userId || lines.length === 0) { if (!userId || lines.length === 0) {
setError("Selecciona un estudiante y al menos un producto."); setError("Selecciona un estudiante y al menos un producto.");
@ -64,7 +52,9 @@ const InvoiceForm = () => {
try { try {
const preparedLines = lines.map((line) => { const preparedLines = lines.map((line) => {
const product = products.find((p) => p.id === parseInt(line.productServiceId)); const product = products.find(
(p) => p.id === parseInt(line.productServiceId)
);
return { return {
productServiceId: parseInt(line.productServiceId), productServiceId: parseInt(line.productServiceId),
quantity: parseInt(line.quantity), quantity: parseInt(line.quantity),
@ -81,7 +71,9 @@ const InvoiceForm = () => {
}; };
const res = await api.post("/invoices/createInvoiceWithLines", payload); const res = await api.post("/invoices/createInvoiceWithLines", payload);
setCreatedInvoice(res.data); const invoiceId = res.data.id;
setCreatedInvoiceId(invoiceId);
alert("✅ Factura creada correctamente."); alert("✅ Factura creada correctamente.");
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -91,14 +83,14 @@ const InvoiceForm = () => {
const downloadPdf = async () => { const downloadPdf = async () => {
try { try {
const response = await api.get(`/invoices/generatePDFById/${createdInvoice.id}`, { const response = await api.get(`/invoices/generatePDFById/${createdInvoiceId}`, {
responseType: "blob", responseType: "blob", // para recibir el PDF correctamente
}); });
const url = window.URL.createObjectURL(new Blob([response.data])); const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;
link.setAttribute("download", `factura_${createdInvoice.id}.pdf`); link.setAttribute("download", `factura_${createdInvoiceId}.pdf`);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
} catch (err) { } catch (err) {
@ -149,11 +141,9 @@ const InvoiceForm = () => {
/> />
))} ))}
<div style={{ marginTop: "1rem" }}> <h4 style={{ marginTop: "1rem" }}>
<h4>Subtotal: <strong>{calculateSubtotal().toFixed(2)} </strong></h4> Total: <strong>{calculateTotal().toFixed(2)} </strong>
<h4>IVA: <strong>{calculateIVA().toFixed(2)} </strong></h4> </h4>
<h4>Total estimado con IVA: <strong>{calculateTotal().toFixed(2)} </strong></h4>
</div>
<ErrorMessage message={error} /> <ErrorMessage message={error} />
@ -170,10 +160,9 @@ const InvoiceForm = () => {
</button> </button>
</div> </div>
{createdInvoice && ( {createdInvoiceId && (
<div style={{ marginTop: "2rem" }}> <div style={{ marginTop: "2rem" }}>
<h4>Factura creada: #{createdInvoice.id}</h4> <h4>Factura creada: #{createdInvoiceId}</h4>
<h4>Total con IVA: <strong>{createdInvoice.total.toFixed(2)} </strong></h4>
<button className="btn btn-primary" onClick={downloadPdf}> <button className="btn btn-primary" onClick={downloadPdf}>
📄 Descargar PDF 📄 Descargar PDF
</button> </button>

View File

@ -1,3 +1,5 @@
// src/components/forms/InvoiceLineItem.jsx
import React from 'react'; import React from 'react';
const InvoiceLineItem = ({ index, line, products, onUpdate, onRemove }) => { const InvoiceLineItem = ({ index, line, products, onUpdate, onRemove }) => {
@ -6,10 +8,6 @@ const InvoiceLineItem = ({ index, line, products, onUpdate, onRemove }) => {
}; };
const selectedProduct = products.find(p => p.id === parseInt(line.productServiceId)); const selectedProduct = products.find(p => p.id === parseInt(line.productServiceId));
const quantity = parseInt(line.quantity) || 1;
const price = selectedProduct?.price || 0;
const ivaPercentage = selectedProduct?.ivaType?.percentage || 0;
const totalConIVA = price * quantity * (1 + ivaPercentage / 100);
return ( return (
<div className="content-area" style={{ marginBottom: "1rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}> <div className="content-area" style={{ marginBottom: "1rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
@ -38,14 +36,9 @@ const InvoiceLineItem = ({ index, line, products, onUpdate, onRemove }) => {
/> />
{selectedProduct && ( {selectedProduct && (
<> <div style={{ fontSize: '0.9rem', color: '#666' }}>
<div style={{ fontSize: '0.9rem', color: '#666' }}> <strong>{selectedProduct.name}</strong>: {selectedProduct.description}
<strong>{selectedProduct.name}</strong>: {selectedProduct.description} </div>
</div>
<div style={{ fontSize: '0.9rem', color: '#333' }}>
<strong>Total con IVA:</strong> {totalConIVA.toFixed(2)}
</div>
</>
)} )}
<button className="btn btn-danger" onClick={() => onRemove(index)}>Eliminar producto</button> <button className="btn btn-danger" onClick={() => onRemove(index)}>Eliminar producto</button>

View File

@ -2,6 +2,7 @@ import React from 'react';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import Topbar from './Topbar'; import Topbar from './Topbar';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import ContentArea from './ContentArea';
import AdminDashboard from '../../pages/admin/AdminDashboard'; import AdminDashboard from '../../pages/admin/AdminDashboard';
import TeacherDashboard from '../../pages/teacher/TeacherDashboard'; import TeacherDashboard from '../../pages/teacher/TeacherDashboard';
import StudentDashboard from '../../pages/student/StudentDashboard'; import StudentDashboard from '../../pages/student/StudentDashboard';
@ -10,7 +11,7 @@ import UserList from '../../components/lists/UserList';
import NotificationCreateForm from '../../components/forms/NotificationCreateForm'; import NotificationCreateForm from '../../components/forms/NotificationCreateForm';
import StudentHistoryCreateForm from '../../components/forms/StudentHistoryCreateForm'; import StudentHistoryCreateForm from '../../components/forms/StudentHistoryCreateForm';
import StudentHistoryList from '../../components/lists/StudentHistoryList'; import StudentHistoryList from '../../components/lists/StudentHistoryList';
import ProfilePage from '../../pages/ProfilePage'; import ProfilePage from '../../pages/ProfilePage'
import NotificationList from '../../components/lists/NotificationList'; import NotificationList from '../../components/lists/NotificationList';
import TrainingGroupForm from '../forms/TrainingGroupFrom'; import TrainingGroupForm from '../forms/TrainingGroupFrom';
import TrainingGroupList from '../lists/TrainingGroupList'; import TrainingGroupList from '../lists/TrainingGroupList';
@ -27,9 +28,9 @@ import PaymentForm from '../forms/PaymentForm';
import PaymentList from '../lists/PaymentList'; import PaymentList from '../lists/PaymentList';
import ProductForm from '../forms/ProductForm'; import ProductForm from '../forms/ProductForm';
import ProductList from '../lists/ProductList'; import ProductList from '../lists/ProductList';
import IVATypeManager from '../forms/IVATypeManager';
import '../styles/MainLayout.css'; import '../styles/MainLayout.css';
const MainLayout = () => { const MainLayout = () => {
return ( return (
<div className="main-layout"> <div className="main-layout">
@ -39,20 +40,27 @@ const MainLayout = () => {
<div className="content-area"> <div className="content-area">
<Routes> <Routes>
{/* Dashboards */} {/* Dashboards principales */}
<Route path="/admin/dashboard" element={<AdminDashboard />} /> <Route path="/admin/dashboard" element={<AdminDashboard />} />
<Route path="/teacher/dashboard" element={<TeacherDashboard />} /> <Route path="/teacher/dashboard" element={<TeacherDashboard />} />
<Route path="/student/dashboard" element={<StudentDashboard />} /> <Route path="/student/dashboard" element={<StudentDashboard />} />
{/* User Management */} {/* User Management - rutas específicas */}
<Route path="/admin/user-management/users/create" element={<UserCreateForm />} /> <Route path="/admin/user-management/users/create" element={<UserCreateForm />} />
<Route path="/admin/user-management/users/list" element={<UserList />} /> <Route path="/admin/user-management/users/list" element={<UserList />} />
<Route path="/admin/user-management/notifications/create" element={<NotificationCreateForm />} /> <Route path="/admin/user-management/notifications/create" element={<NotificationCreateForm />} />
<Route path="/admin/user-management/notifications/list" element={<NotificationList />} /> <Route path="/admin/user-management/notifications/list" element={<NotificationList />} />
{/* Student History */}
<Route path="/admin/user-management/student-history/create" element={<StudentHistoryCreateForm />} /> <Route path="/admin/user-management/student-history/create" element={<StudentHistoryCreateForm />} />
<Route path="/admin/user-management/student-history/list" element={<StudentHistoryList />} /> <Route path="/admin/user-management/student-history/list" element={<StudentHistoryList />} />
{/* Class Management */} {/* ContentArea general para secciones sin componentes específicos */}
<Route path="/admin/user-management/*" element={<ContentArea />} />
<Route path="/admin/class-management/*" element={<ContentArea />} />
<Route path="/admin/finance/*" element={<ContentArea />} />
{/*Class Management - rutas específicas*/}
<Route path="/admin/class-management/training-groups/create" element={<TrainingGroupForm />} /> <Route path="/admin/class-management/training-groups/create" element={<TrainingGroupForm />} />
<Route path="/admin/class-management/training-groups/list" element={<TrainingGroupList />} /> <Route path="/admin/class-management/training-groups/list" element={<TrainingGroupList />} />
<Route path="/admin/class-management/training-groups/manage-students" element={<TrainingGroupStudentManager />} /> <Route path="/admin/class-management/training-groups/manage-students" element={<TrainingGroupStudentManager />} />
@ -63,17 +71,19 @@ const MainLayout = () => {
<Route path="/admin/class-management/memberships/create" element={<MembershipForm />} /> <Route path="/admin/class-management/memberships/create" element={<MembershipForm />} />
<Route path="/admin/class-management/memberships/list" element={<MembershipList />} /> <Route path="/admin/class-management/memberships/list" element={<MembershipList />} />
{/* Finance */}
<Route path="/admin/finance/invoices/create" element={<InvoiceForm />} /> <Route path="/admin/finance/invoices/create" element={<InvoiceForm />} />
<Route path="/admin/finance/invoices/list" element={<InvoiceList />} /> <Route path="/admin/finance/invoices/list" element={<InvoiceList />} />
<Route path="/admin/finance/payments/create" element={<PaymentForm />} /> <Route path="/admin/finance/payments/create" element={<PaymentForm />} />
<Route path="/admin/finance/payments/list" element={<PaymentList />} /> <Route path="/admin/finance/payments/list" element={<PaymentList />} />
<Route path="/admin/finance/products/create" element={<ProductForm />} /> <Route path="/admin/finance/products/create" element={<ProductForm />} />
<Route path="/admin/finance/products/list" element={<ProductList />} /> <Route path="/admin/finance/products/list" element={<ProductList />} />
<Route path="/admin/finance/ivatypes/create" element={<IVATypeManager />} />
{/* Perfil */}
{/* Profile Page*/}
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
</Routes> </Routes>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
width: 320px; width: 320px;
background-color: #ffffff; background-color: #ffffff;
color: #2d3436; color: #2d3436;
padding: 0; padding: 0; /* importante para evitar que el padding afecte la altura */
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.05); box-shadow: 2px 0 10px rgba(0, 0, 0, 0.05);
height: 100vh; height: 100vh;
display: flex; display: flex;
@ -44,5 +44,4 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
min-height: 0;
} }