Compare commits
2 Commits
c81a1ea588
...
67b11504a7
| Author | SHA1 | Date |
|---|---|---|
|
|
67b11504a7 | |
|
|
52918e3393 |
|
|
@ -0,0 +1 @@
|
|||
/docker/mysql-data/
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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:
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue