Compare commits

...

2 Commits

Author SHA1 Message Date
Dennis Eckerskorn 67b11504a7 Updated docker file, created backup and changed to local persistence for DB 2025-05-16 00:28:45 +02:00
Dennis Eckerskorn 52918e3393 Added IVATypes 2025-05-15 23:28:51 +02:00
11 changed files with 2094 additions and 59 deletions

1
.gitignore vendored Normal file
View File

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

1885
docker/backup.sql Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -131,10 +131,23 @@ public class InvoiceService extends AbstractService<Invoice, Integer> {
}
public void updateInvoiceTotal(Invoice invoice) {
BigDecimal total = invoice.getInvoiceLines().stream()
.map(InvoiceLine::getSubtotal)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal total = BigDecimal.ZERO;
for (InvoiceLine line : invoice.getInvoiceLines()) {
if (line.getUnitPrice() != null && line.getQuantity() != null) {
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);
}

View File

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

View File

@ -1,5 +1,3 @@
// src/components/forms/InvoiceLineItem.jsx
import React from 'react';
const InvoiceLineItem = ({ index, line, products, onUpdate, onRemove }) => {
@ -8,6 +6,10 @@ const InvoiceLineItem = ({ index, line, products, onUpdate, onRemove }) => {
};
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 (
<div className="content-area" style={{ marginBottom: "1rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
@ -36,9 +38,14 @@ const InvoiceLineItem = ({ index, line, products, onUpdate, onRemove }) => {
/>
{selectedProduct && (
<>
<div style={{ fontSize: '0.9rem', color: '#666' }}>
<strong>{selectedProduct.name}</strong>: {selectedProduct.description}
</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>

View File

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

View File

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