Commit inicial - Sistema de Gestion Hotelera Hacienda San Angel
- Backend Node.js/Express con PostgreSQL - Frontend React 19 con Vite - Docker Compose para orquestacion - Documentacion completa en README.md - Scripts SQL para base de datos - Configuracion de ejemplo (.env.example)
This commit is contained in:
42
frontend/Frontend-Hotel/src/App.css
Normal file
42
frontend/Frontend-Hotel/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
142
frontend/Frontend-Hotel/src/App.jsx
Normal file
142
frontend/Frontend-Hotel/src/App.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from "react";
|
||||
import { BrowserRouter as Router,Routes, Route } from "react-router-dom";
|
||||
import Layout from "./components/Layout2.jsx";
|
||||
import Login from "./pages/Login.jsx";
|
||||
import LoginAssets from "./assets/pages/login.jsx";
|
||||
import BasePositiva from "./pages/BasePositiva.jsx";
|
||||
import Sifen from "./pages/Sifen.jsx";
|
||||
import NotFound from "./pages/NotFound.jsx";
|
||||
import RoomManagement from "./pages/Settings/RoomsManagement.jsx";
|
||||
//Submenú de Dashboard
|
||||
import Income from "./pages/Dashboard/Income.jsx";
|
||||
import Employees from "./pages/Employees.jsx";
|
||||
import Contracts from "./pages/Contracts.jsx";
|
||||
import HotelPL from "./pages/Dashboard/HotelPL.jsx";
|
||||
import RestaurantPL from "./pages/Dashboard/RestaurantPL.jsx";
|
||||
import RestaurantAnalysis from "./pages/Dashboard/RestaurantAnalysis.jsx";
|
||||
import Budget from "./pages/Dashboard/Budget.jsx";
|
||||
import CostPerRoom from "./pages/Dashboard/CostPerRoom.jsx";
|
||||
import Expenses from "./pages/Dashboard/Expenses.jsx";
|
||||
import RoomAnalysis from "./pages/Dashboard/RoomAnalysis.jsx";
|
||||
//Submenú de Expenses to be approved
|
||||
import PendingApproval from "./pages/PendingApproval.jsx";
|
||||
import Approved from "./pages/ExpensesToBeApproval/Approved.jsx";
|
||||
import Rejected from "./pages/ExpensesToBeApproval/Rejected.jsx";
|
||||
//Submenú de Expenses
|
||||
import ReportExpense from "./pages/Expenses/ReportExpense.jsx";
|
||||
import Payments from "./pages/Expenses/Payments.jsx";
|
||||
import MonthlyPayments from "./pages/Expenses/MonthlyPayments.jsx";
|
||||
import MonthlyReport from "./pages/Expenses/MonthlyReport.jsx";
|
||||
import ExpenseDetail from "./pages/Expenses/ExpenseDetail.jsx";
|
||||
import EditExpense from "./pages/Expenses/EditExpense.jsx";
|
||||
import NewExpense from "./pages/Expenses/NewExpense.jsx";
|
||||
import PurchaseEntries from "./pages/Expenses/PurchaseEntries.jsx";
|
||||
import NewSuppliers from "./pages/Expenses/NewSuppliers.jsx";
|
||||
//Submenú de Inventory
|
||||
import Products from "./pages/Inventory/Products.jsx";
|
||||
import NewProduct from "./pages/Inventory/NewProduct.jsx";
|
||||
import InventoryReport from "./pages/Inventory/InventoryReport.jsx";
|
||||
import NewMonthlyPayment from "./pages/Expenses/NewMonthlyPayment.jsx";
|
||||
import Outcomes from "./pages/Inventory/Outcomes.jsx";
|
||||
import HousekeeperOutcomes from "./pages/Inventory/HousekeeperOutcomes.jsx";
|
||||
|
||||
import "./styles/global.css";
|
||||
//Submenú de Hotel
|
||||
import Properties from "./pages/Hotel/Properties.jsx";
|
||||
import PropertyDetailPage from "./pages/Hotel/PropertiesId.jsx";
|
||||
|
||||
//Submenú de Payroll
|
||||
import Payroll from "./pages/Payroll/Payroll.jsx";
|
||||
import Settings from "./pages/Settings/Settings.jsx";
|
||||
import SettingsId from "./pages/Settings/SettingsId.jsx";
|
||||
import Plantillapayroll from "./pages/Payroll/Plantillapayroll.jsx";
|
||||
import ContractsDetail from "./pages/Payroll/ContractsDetail.jsx";
|
||||
import PayrollContract from "./pages/Payroll/PayrollContract.jsx";
|
||||
import EditPayroll from "./pages/Payroll/EditPayroll.jsx";
|
||||
|
||||
//Submenú de Inventory
|
||||
|
||||
import Adjustments from "./pages/Inventory/Adjustments.jsx";
|
||||
import AlterProduct from "./pages/Inventory/AlterProduct.jsx";
|
||||
import PayrollAttendance from "./pages/Payroll/PayrollAttendance.jsx";
|
||||
import PayrollEmployees from "./pages/Payroll/PayrollEmployees.jsx";
|
||||
import PayrollNewEmployee from "./pages/Payroll/NewEmployee.jsx";
|
||||
|
||||
import NewIncome from "./pages/Income/NewIncome.jsx";
|
||||
import IncomeReport from "./pages/Income/IncomeReport.jsx";
|
||||
import DiscardProduct from "./pages/Inventory/DiscardProduct.jsx";
|
||||
|
||||
export default function App() {
|
||||
// const location = useLocation();
|
||||
// const isLogin = location.pathname === "/";
|
||||
return (
|
||||
<>
|
||||
{/* <Navbar /> */}
|
||||
{/**{!isLogin && <Navbar />} */}
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/app" element={<Layout />}>
|
||||
<Route path="login" element={<LoginAssets />} />
|
||||
<Route path="base-positiva" element={<BasePositiva />} />
|
||||
<Route path="sifen" element={<Sifen />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
<Route path="income" element={<Income/>} />
|
||||
<Route path="employees" element={<Employees />} />
|
||||
<Route path="contracts" element={<Contracts />} />
|
||||
<Route path="hotelpl" element={<HotelPL />} />
|
||||
<Route path="restaurantpl" element={<RestaurantPL />} />
|
||||
<Route path="restaurant-analysis" element={<RestaurantAnalysis />} />
|
||||
<Route path="budget" element={<Budget />} />
|
||||
<Route path="cost-per-room" element={<CostPerRoom />} />
|
||||
<Route path="room-analysis" element={<RoomAnalysis />} />
|
||||
<Route path="expenses" element={<Expenses />} />
|
||||
<Route path="new-income-report" element={<IncomeReport />} />
|
||||
<Route path="new-income-form" element={<NewIncome />} />
|
||||
<Route path="edit-income-form/:id" element={<NewIncome />} />
|
||||
<Route path="payments" element={<Payments />} />
|
||||
<Route path="pending-approval" element={<PendingApproval />} />
|
||||
<Route path="approved" element={<Approved />} />
|
||||
<Route path="rejected" element={<Rejected />} />
|
||||
<Route path="report-expense" element={<ReportExpense />} />
|
||||
<Route path="new-expense" element={<NewExpense />} />
|
||||
<Route path="monthly-payments" element={<MonthlyPayments />} />
|
||||
<Route path="expenses/edit/:id" element={<EditExpense />} />
|
||||
<Route path="expenses/:id" element={<ExpenseDetail />} />
|
||||
<Route path="purchase-entries" element={<PurchaseEntries />} />
|
||||
<Route path="products" element={<Products />} />
|
||||
<Route path="properties" element={<Properties />} />
|
||||
<Route path="payroll" element={<Payroll />} />
|
||||
<Route path="properties/:id" element={<PropertyDetailPage />} />
|
||||
<Route path="new-product" element={<NewProduct/>} />
|
||||
<Route path="inventory-report" element={<InventoryReport />} />
|
||||
<Route path="settings" element={<Settings/>} />
|
||||
<Route path="settings/room-management" element={<RoomManagement />} />
|
||||
<Route path="settings/settings-id" element={<SettingsId />} />
|
||||
<Route path="new-monthly" element={<NewMonthlyPayment/>} />
|
||||
<Route path="new-suppliers" element={<NewSuppliers/>} />
|
||||
<Route path="monthly-report" element={<MonthlyReport />} />
|
||||
|
||||
<Route path="discard-product" element={<DiscardProduct />} />
|
||||
<Route path="adjustments" element={<Adjustments />} />
|
||||
<Route path="product-adjustments" element={<Adjustments />} />
|
||||
<Route path="payroll/employees" element={<PayrollEmployees />} />
|
||||
<Route path="payroll/attendance" element={<PayrollAttendance />} />
|
||||
<Route path="payroll/newemployee" element={<PayrollNewEmployee />} />
|
||||
<Route path="payroll/employee/:id" element={<PayrollNewEmployee />} />
|
||||
<Route path="payroll/contract" element={<PayrollContract />} />
|
||||
<Route path="payroll/contract/:id" element={<PayrollContract />} />
|
||||
<Route path="payroll/edit/:id" element={<EditPayroll />} />
|
||||
<Route path="payroll/:id" element={<Plantillapayroll />} />
|
||||
<Route path="payroll/contracts-detail/:id" element={<ContractsDetail />} />
|
||||
<Route path="alter-product/:id" element={<AlterProduct />} />
|
||||
<Route path="inventory/outcomes" element={<Outcomes />} />
|
||||
<Route path="housekeeper/outcomes" element={<HousekeeperOutcomes />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* <Route path="users" element={<Users/>} />
|
||||
<Route path="reportes" element={<Reportes />} />
|
||||
*/
|
||||
63
frontend/Frontend-Hotel/src/assets/pages/login.jsx
Normal file
63
frontend/Frontend-Hotel/src/assets/pages/login.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
import "../../components/Buttons/Button";
|
||||
function LoginAssets() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
alert(`Email: ${email}, Password: ${password}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-[#e0b400]">
|
||||
<div className="bg-white shadow-lg rounded-lg flex p-10 max-w-3xl w-full">
|
||||
{/* Logo */}
|
||||
<div className="flex justify-center items-center w-1/2">
|
||||
<img
|
||||
src="/logo.png" // 👈 coloca aquí tu logo (ponlo en public/logo.png)
|
||||
alt="Logo"
|
||||
className="max-h-40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Formulario */}
|
||||
<div className="flex flex-col justify-center w-1/2">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<button className="gray">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="EMAIL/USER"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-full bg-gray-100 focus:outline-none focus:ring-2 focus:ring-[#720019]"
|
||||
/>
|
||||
</button>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="PASSWORD"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#720019] text-white font-bold px-6 py-2 rounded-full hover:bg-[#5a0014] transition"
|
||||
>
|
||||
LOG IN
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-[#720019] text-white font-bold px-6 py-2 rounded-full hover:bg-[#5a0014] transition"
|
||||
>
|
||||
RECOVER PASSWORD
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginAssets;
|
||||
1
frontend/Frontend-Hotel/src/assets/react.svg
Normal file
1
frontend/Frontend-Hotel/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
89
frontend/Frontend-Hotel/src/components/Buttons/Button.css
Normal file
89
frontend/Frontend-Hotel/src/components/Buttons/Button.css
Normal file
@@ -0,0 +1,89 @@
|
||||
.btn {
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
border-radius: 999px; /* Súper redondo */
|
||||
background-color: #521414;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
cursor: pointer;
|
||||
color: #ffffff;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0px 8px 15px rgba(0, 0, 0, 0.2);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background-color: #4a0d0d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0px 12px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn.secundary {
|
||||
border: none;
|
||||
padding: 20px 40px;
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
border-radius: 999px;
|
||||
background-color: #eeeeee;
|
||||
cursor: pointer;
|
||||
color: #555555;
|
||||
box-shadow: 0px 8px 15px rgba(0, 0, 0, 0.15);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn.secundary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0px 12px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-top: 30px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-top: 30px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
background-color: #eeeeee;
|
||||
color: #555;
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
border-radius: 999px;
|
||||
padding: 20px 40px;
|
||||
box-shadow: 0px 8px 15px rgba(0, 0, 0, 0.15);
|
||||
border: none;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn.gray {
|
||||
background-color: #ccc;
|
||||
color: #333;
|
||||
}
|
||||
*/
|
||||
18
frontend/Frontend-Hotel/src/components/Buttons/Button.jsx
Normal file
18
frontend/Frontend-Hotel/src/components/Buttons/Button.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
// src/components/Button.jsx
|
||||
import React from "react";
|
||||
import "./Button.css"; // estilos separados
|
||||
|
||||
function Button({ label, onClick, variant = "primary" | "secundary"}) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Login</h1>
|
||||
<Button variant="primary" onClick={() => alert("Ingresando...")}>
|
||||
Iniciar sesión
|
||||
</Button>
|
||||
<Button variant="secundary" onClick={() => alert("Registro")}>
|
||||
Registrarse
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default Button;
|
||||
118
frontend/Frontend-Hotel/src/components/ExcelExportButton.jsx
Normal file
118
frontend/Frontend-Hotel/src/components/ExcelExportButton.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useContext } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import * as XLSX from "xlsx";
|
||||
import { FiDownload } from "react-icons/fi";
|
||||
import { langContext } from "../context/LenguageContext";
|
||||
|
||||
export default function ExcelExportButton({
|
||||
data,
|
||||
columns,
|
||||
filenamePrefix,
|
||||
sheetName,
|
||||
dataTransform = null,
|
||||
className = "",
|
||||
style = {},
|
||||
}) {
|
||||
const { lang } = useContext(langContext);
|
||||
|
||||
const handleExportToExcel = () => {
|
||||
if (!data || data.length === 0) {
|
||||
alert(lang === "es" ? "No hay datos para exportar" : "No data to export");
|
||||
return;
|
||||
}
|
||||
|
||||
let dataToExport = dataTransform ? dataTransform(data) : data;
|
||||
|
||||
const excelData = dataToExport.map((row) => {
|
||||
const excelRow = {};
|
||||
columns.forEach((column) => {
|
||||
if (column.key) {
|
||||
let value = row[column.key];
|
||||
|
||||
// Handle render functions (extract text value)
|
||||
if (column.render && typeof column.render === "function") {
|
||||
// Try to get the raw value if render is used
|
||||
value = row[column.key];
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
value = isNaN(value.getTime())
|
||||
? ""
|
||||
: value.toLocaleDateString(lang === "es" ? "es-MX" : "en-US");
|
||||
}
|
||||
|
||||
const header =
|
||||
typeof column.header === "string"
|
||||
? column.header
|
||||
: (lang === "es" ? column.header?.es : column.header?.en) ||
|
||||
column.key;
|
||||
|
||||
excelRow[header] = value || "";
|
||||
}
|
||||
});
|
||||
return excelRow;
|
||||
});
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(excelData);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
const finalSheetName =
|
||||
typeof sheetName === "string"
|
||||
? sheetName
|
||||
: (lang === "es" ? sheetName?.es : sheetName?.en) || "Sheet1";
|
||||
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, finalSheetName);
|
||||
|
||||
const today = new Date();
|
||||
const dateStr = today.toISOString().split("T")[0];
|
||||
const filename = `${filenamePrefix}-${dateStr}.xlsx`;
|
||||
|
||||
XLSX.writeFile(workbook, filename);
|
||||
};
|
||||
|
||||
const defaultStyle = {
|
||||
backgroundColor: "#ffcb05",
|
||||
color: "#fff",
|
||||
padding: "10px 20px",
|
||||
fontWeight: "bold",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
fontSize: "14px",
|
||||
transition: "background-color 0.3s ease",
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleExportToExcel}
|
||||
className={className}
|
||||
style={defaultStyle}
|
||||
onMouseEnter={(e) => (e.target.style.backgroundColor = "#f4b400")}
|
||||
onMouseLeave={(e) => (e.target.style.backgroundColor = "#ffcb05")}
|
||||
>
|
||||
<FiDownload />
|
||||
{lang === "es" ? "Exportar a Excel" : "Export to Excel"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
ExcelExportButton.propTypes = {
|
||||
data: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
columns: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
header: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
||||
key: PropTypes.string.isRequired,
|
||||
render: PropTypes.func,
|
||||
})
|
||||
).isRequired,
|
||||
filenamePrefix: PropTypes.string.isRequired,
|
||||
sheetName: PropTypes.oneOfType([PropTypes.string, PropTypes.object])
|
||||
.isRequired,
|
||||
dataTransform: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
.date-range-filter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.date-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
min-width: 150px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.date-input:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.date-input:hover {
|
||||
box-shadow: 0 0 0 2px #e6e6e6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.date-range-filter {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.date-input-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import './DateRangeFilter.css';
|
||||
|
||||
const DateRangeFilter = ({
|
||||
dateRange,
|
||||
onDateChange,
|
||||
labels = { from: 'From:', to: 'To:' },
|
||||
lang = 'en'
|
||||
}) => {
|
||||
const fromLabel = lang === 'en' ? labels.from : 'Desde:';
|
||||
const toLabel = lang === 'en' ? labels.to : 'Hasta:';
|
||||
|
||||
return (
|
||||
<div className="date-range-filter">
|
||||
<div className="date-input-group">
|
||||
<label htmlFor="date-from" className="date-label">
|
||||
{fromLabel}
|
||||
</label>
|
||||
<input
|
||||
id="date-from"
|
||||
type="date"
|
||||
className="date-input"
|
||||
value={dateRange.from}
|
||||
onChange={(e) => onDateChange({ ...dateRange, from: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="date-input-group">
|
||||
<label htmlFor="date-to" className="date-label">
|
||||
{toLabel}
|
||||
</label>
|
||||
<input
|
||||
id="date-to"
|
||||
type="date"
|
||||
className="date-input"
|
||||
value={dateRange.to}
|
||||
onChange={(e) => onDateChange({ ...dateRange, to: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateRangeFilter;
|
||||
|
||||
265
frontend/Frontend-Hotel/src/components/Filters/Filters.css
Normal file
265
frontend/Frontend-Hotel/src/components/Filters/Filters.css
Normal file
@@ -0,0 +1,265 @@
|
||||
/* src/styles/Filters.css */
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: #1a1a1a;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.new-payment-btn {
|
||||
background-color: #FFD700;
|
||||
color: #800000;
|
||||
font-weight: bold;
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.new-payment-btn:hover {
|
||||
background-color: #fcd200;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.new-payment-btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.report-page {
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
select,
|
||||
input[type="date"] {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
max-width: 250px;
|
||||
min-width: 150px;
|
||||
width: auto;
|
||||
flex: 0 1 auto;
|
||||
/* appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none; */
|
||||
/* background-image: url("data:image/svg+xml,%3Csvg fill='gold' viewBox='0 0 24 24' width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 2px center;
|
||||
background-size: 18px 18px; */
|
||||
font-family:'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
/* display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px; */
|
||||
|
||||
}
|
||||
|
||||
select:focus,
|
||||
input[type="date"]:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filter-select, .filter-date {
|
||||
background-color: white;
|
||||
border: none;
|
||||
border-radius: 2rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
box-shadow: 0 0 3px rgba(0,0,0,0.1);
|
||||
appearance: none;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg fill="gold" ...>...</svg>'); /* usa ícono amarillo aquí */
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
background-size: 1rem;
|
||||
}
|
||||
|
||||
.filter-select:focus, .filter-date:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #FFD700;
|
||||
}
|
||||
|
||||
.status-button {
|
||||
border: 2px solid;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.status-button.approve {
|
||||
color: #28a745;
|
||||
border-color: #28a745;
|
||||
background-color: #e6fff0;
|
||||
}
|
||||
|
||||
.status-button.approved {
|
||||
color: #28a745;
|
||||
border-color: #28a745;
|
||||
background-color: #e6fff0;
|
||||
}
|
||||
|
||||
.status-button.reject {
|
||||
color: #dc3545;
|
||||
border-color: #dc3545;
|
||||
background-color: #ffe6e6;
|
||||
}
|
||||
.status-button.rejected {
|
||||
color: #dc3545;
|
||||
border-color: #dc3545;
|
||||
background-color: #ffe6e6;
|
||||
}
|
||||
|
||||
.status-button.pending {
|
||||
color: #f0ad4e;
|
||||
border-color: #f0ad4e;
|
||||
background-color: #fff7e6;
|
||||
}
|
||||
|
||||
.status-button.paid {
|
||||
color: #28a745;
|
||||
border-color: #28a745;
|
||||
background-color: #e6fff0;
|
||||
}
|
||||
|
||||
|
||||
.income-card {
|
||||
background-color: white;
|
||||
border-radius: 15px;
|
||||
padding: 15px 20px;
|
||||
min-width: 140px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.summary-container {
|
||||
display: flex;
|
||||
gap: 80px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* .summary-card {
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
padding: 10px 20px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
font-size: 1rem;
|
||||
min-width: 160px;
|
||||
} */
|
||||
|
||||
|
||||
.summary-card {
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
padding: 15px 25px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
min-width: 140px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
align-items: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*Date*/
|
||||
.date-filter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-family:'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.date-filter-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding-left: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
/*
|
||||
.status-button.approve {
|
||||
background-color: #8bed92;
|
||||
border: 1.5px solid #33a544;
|
||||
color: #33a544;
|
||||
border-radius: 20px;
|
||||
padding: 5px 15px;
|
||||
font-weight: bold;
|
||||
pointer-events: none;
|
||||
} */
|
||||
|
||||
|
||||
.page-filters {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.approved-page h2 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
.status-button {
|
||||
border: 2px solid #28a745;
|
||||
color: #28a745;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: bold;
|
||||
background-color: #e6fff0;
|
||||
}
|
||||
|
||||
.status-button.inactive {
|
||||
border-color: #dc3545;
|
||||
color: #dc3545;
|
||||
background-color: #ffe6e6;
|
||||
}
|
||||
*/
|
||||
99
frontend/Frontend-Hotel/src/components/Filters/Filters.jsx
Normal file
99
frontend/Frontend-Hotel/src/components/Filters/Filters.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
// src/components/Filters/Filters.jsx
|
||||
import React from 'react';
|
||||
import './Filters.css';
|
||||
|
||||
const Filters = ({
|
||||
areaOptions = [],
|
||||
statusOptions = [],
|
||||
selectedArea,
|
||||
selectedStatus,
|
||||
onAreaChange,
|
||||
onStatusChange,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="filters-container">
|
||||
{areaOptions.length > 0 && (
|
||||
<div className="filter-select-wrapper">
|
||||
<select className="filter-select" value={selectedArea} onChange={onAreaChange}>
|
||||
{areaOptions.map((area, index) => (
|
||||
<option key={index} value={area}>
|
||||
{area}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statusOptions.length > 0 && (
|
||||
<div className="filter-select-wrapper">
|
||||
<select className="filter-select" value={selectedStatus} onChange={onStatusChange}>
|
||||
{statusOptions.map((status, index) => (
|
||||
<option key={index} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onStartDateChange && (
|
||||
<input
|
||||
type="date"
|
||||
className="filter-date"
|
||||
value={startDate}
|
||||
onChange={onStartDateChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{onEndDateChange && (
|
||||
<input
|
||||
type="date"
|
||||
className="filter-date"
|
||||
value={endDate}
|
||||
onChange={onEndDateChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
|
||||
|
||||
|
||||
// src/components/Filters.jsx
|
||||
// import React from 'react';
|
||||
// import '../styles/Filters.css';
|
||||
|
||||
// export default function Filters({ area, status, startDate, endDate, onChange }) {
|
||||
// return (
|
||||
// <div className="filters">
|
||||
// <select value={area} onChange={(e) => onChange('area', e.target.value)}>
|
||||
// <option value="">Area: Hotel, Restaurant</option>
|
||||
// <option value="Hotel">Hotel</option>
|
||||
// <option value="Restaurant">Restaurant</option>
|
||||
// </select>
|
||||
|
||||
// <select value={status} onChange={(e) => onChange('status', e.target.value)}>
|
||||
// <option value="">Status: Active, Inactive</option>
|
||||
// <option value="Active">Active</option>
|
||||
// <option value="Inactive">Inactive</option>
|
||||
// </select>
|
||||
|
||||
// <input
|
||||
// type="date"
|
||||
// value={startDate}
|
||||
// onChange={(e) => onChange('startDate', e.target.value)}
|
||||
// />
|
||||
// <input
|
||||
// type="date"
|
||||
// value={endDate}
|
||||
// onChange={(e) => onChange('endDate', e.target.value)}
|
||||
// />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
32
frontend/Frontend-Hotel/src/components/FormInput.jsx
Normal file
32
frontend/Frontend-Hotel/src/components/FormInput.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
// export default function FormInput({ label, name, value, onChange, ...rest }) {
|
||||
// return (
|
||||
// <div className="form-input">
|
||||
// {label && <label>{label}</label>}
|
||||
// <input
|
||||
// name={name}
|
||||
// value={value}
|
||||
// onChange={onChange}
|
||||
// {...rest}
|
||||
// />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
|
||||
|
||||
export default function FormInput({ label, name, value, onChange, placeholder, type = "text", ...props }) {
|
||||
return (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
frontend/Frontend-Hotel/src/components/FormSelect.jsx
Normal file
13
frontend/Frontend-Hotel/src/components/FormSelect.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export default function FormSelect({ label, name, value, onChange, options }) {
|
||||
return (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<select name={name} value={value} onChange={onChange}>
|
||||
<option value="">Select</option>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
frontend/Frontend-Hotel/src/components/Inputs/Input.css
Normal file
8
frontend/Frontend-Hotel/src/components/Inputs/Input.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ccc;
|
||||
font-size: 1rem;
|
||||
}
|
||||
17
frontend/Frontend-Hotel/src/components/Inputs/Input.jsx
Normal file
17
frontend/Frontend-Hotel/src/components/Inputs/Input.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
// src/components/Input.jsx
|
||||
import React from "react";
|
||||
import "./Input.css";
|
||||
|
||||
function Input({ type = "text", placeholder, value, onChange }) {
|
||||
return (
|
||||
<input
|
||||
className="input"
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Input;
|
||||
357
frontend/Frontend-Hotel/src/components/Layout.jsx
Normal file
357
frontend/Frontend-Hotel/src/components/Layout.jsx
Normal file
@@ -0,0 +1,357 @@
|
||||
// Layout.jsx
|
||||
// import React, { useState } from "react";
|
||||
// import { Outlet, NavLink, useLocation } from "react-router-dom";
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
// import { FaBell, FaCog } from "react-icons/fa";
|
||||
// import "../styles/Dashboard.css";
|
||||
|
||||
// const menuConfig = [
|
||||
// {
|
||||
// label: "Dashboards",
|
||||
// basePath: "/app/income",
|
||||
// submenu: [
|
||||
// { label: "Income", route: "/app/income" },
|
||||
// { label: "Expenses", route: "/app/expenses" },
|
||||
// { label: "Cost per room", route: "/app/cost-per-room" },
|
||||
// { label: "Budget", route: "/app/budget" },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// label: "Expenses to be approved",
|
||||
// basePath: "/app/pending-approval",
|
||||
// submenu: [
|
||||
// { label: "Pending approval", route: "/app/pending-approval" },
|
||||
// { label: "Approved", route: "/app/approved" },
|
||||
// { label: "Rejected", route: "/app/rejected" },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// label: "Expenses",
|
||||
// basePath: "/app/report",
|
||||
// submenu: [
|
||||
// { label: "Report", route: "/app/report" },
|
||||
// { label: "New Expense", route: "/app/new-expense" },
|
||||
// { label: "Payments", route: "/app/payments" },
|
||||
// { label: "Monthly Payments", route: "/app/monthly-payments" },
|
||||
// { label: "Id", route: "/app/expense-id" },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// label: "Inventory",
|
||||
// basePath: "/app/products",
|
||||
// submenu: [
|
||||
// { label: "Products", route: "/app/products" },
|
||||
// { label: "New Product", route: "/app/new-product" },
|
||||
// { label: "Report", route: "/app/inventory-report" },
|
||||
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// label: "Payroll",
|
||||
// basePath: "/app/payroll",
|
||||
// submenu: [
|
||||
// { label: "Report", route: "/app/payroll" },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// label: "Hotel",
|
||||
// basePath: "/app/properties",
|
||||
// submenu: [
|
||||
// { label: "Properties", route: "/app/properties" },
|
||||
// // { label: "New Property", route: "/app/properties/:id"},
|
||||
// ],
|
||||
// },
|
||||
|
||||
// ];
|
||||
|
||||
// export default function Layout() {
|
||||
// const navigate = useNavigate();
|
||||
// const location = useLocation();
|
||||
// const [isSidebarOpen, setSidebarOpen] = useState(true);
|
||||
// const toggleSidebar = () => setSidebarOpen(!isSidebarOpen);
|
||||
// const isDetailPage = /^\/app\/properties\/\d+$/.test(location.pathname);
|
||||
// const isSettingsPage = location.pathname === "/app/settings";
|
||||
// // Detectar qué menú está activo según la ruta actual
|
||||
// // const activeSection = menuConfig.find(section =>
|
||||
// // location.pathname.startsWith(section.basePath)
|
||||
// // );
|
||||
|
||||
// // const activeSection = menuConfig.find(section =>
|
||||
// // section.submenu.some(item => location.pathname.startsWith(item.route))
|
||||
// // );
|
||||
|
||||
// // Encuentra la sección activa o ignórala si es una ruta especial como "/app/properties/:id"
|
||||
// const activeSection = menuConfig.find(section =>
|
||||
// section.submenu.some(item => location.pathname.startsWith(item.route))
|
||||
// );
|
||||
|
||||
// // Si no hay sección activa, es una página sin menú (como detalles)
|
||||
// const activeSubmenu = activeSection?.submenu || [];
|
||||
|
||||
// return (
|
||||
// <div className="dashboard-layout">
|
||||
// {/* Sidebar */}
|
||||
// {isSidebarOpen && (
|
||||
// <aside className="sidebar">
|
||||
// <nav>
|
||||
// <NavLink to="/app/income">Dashboards</NavLink>
|
||||
// <NavLink to="/app/pending-approval">Expenses to be approved</NavLink>
|
||||
// <NavLink to="/app/report">Expenses</NavLink>
|
||||
// <NavLink to="/app/products">Inventory</NavLink>
|
||||
// <NavLink to="/app/payroll">Payroll</NavLink>
|
||||
// <NavLink to="/app/properties">Hotel</NavLink>
|
||||
// </nav>
|
||||
// </aside>
|
||||
// )}
|
||||
|
||||
// {/* Main content */}
|
||||
// <div className="main-content">
|
||||
// {/* Topbar */}
|
||||
// <div className="topbar">
|
||||
// <div className="topbar-header">
|
||||
// {/* Oculta título si estamos en /app/settings */}
|
||||
// {!isSettingsPage && (
|
||||
// <div className="topbar-title">{activeSection?.label}</div>
|
||||
// )}
|
||||
|
||||
// <div className="topbar-icons">
|
||||
// <FaBell className="topbar-icon" />
|
||||
// <FaCog
|
||||
// className="topbar-icon cursor-pointer"
|
||||
// onClick={() => navigate("/app/settings")}
|
||||
// />
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {/*Oculta submenú si es página de detalles o settings */}
|
||||
// {!isDetailPage && !isSettingsPage && (
|
||||
// <div className="topbar-submenu">
|
||||
// {activeSubmenu.map((item, index) => (
|
||||
// <NavLink
|
||||
// key={index}
|
||||
// to={item.route}
|
||||
// className={({ isActive }) =>
|
||||
// isActive ? "submenu-link active" : "submenu-link"
|
||||
// }
|
||||
// >
|
||||
// {item.label}
|
||||
// </NavLink>
|
||||
// ))}
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
|
||||
// {/* Página actual */}
|
||||
// <div className="content">
|
||||
// <Outlet />
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
|
||||
// return (
|
||||
// <div className="dashboard-layout">
|
||||
// {/* Sidebar */}
|
||||
// {isSidebarOpen && (
|
||||
// <aside className="sidebar">
|
||||
// <nav>
|
||||
// <NavLink to="/app/income">Dashboards</NavLink>
|
||||
// <NavLink to="/app/pending-approval">Expenses to be approved</NavLink>
|
||||
// <NavLink to="/app/report">Expenses</NavLink>
|
||||
// <NavLink to="/app/products">Inventory</NavLink>
|
||||
// <NavLink to="/app/payroll">Payroll</NavLink>
|
||||
// <NavLink to="/app/properties">Hotel</NavLink>
|
||||
// </nav>
|
||||
// </aside>
|
||||
// )}
|
||||
|
||||
// {/* Contenido principal */}
|
||||
// <div className="main-content">
|
||||
// {/* ÚNICO Topbar */}
|
||||
// <div className="topbar">
|
||||
// {/* Línea superior: título + iconos */}
|
||||
// <div className="topbar-header">
|
||||
// <div className="topbar-title">{activeSection?.label}</div>
|
||||
// <div className="topbar-icons">
|
||||
// <FaBell className="topbar-icon" />
|
||||
// <FaCog className="topbar-icon cursor-pointer" onClick={() => navigate("/app/settings")} />
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {/* Línea inferior: submenú dinámico */}
|
||||
|
||||
// {/* Línea inferior: submenú dinámico */}
|
||||
// {!isDetailPage && (
|
||||
// <div className="topbar-submenu">
|
||||
// {activeSubmenu.map((item, index) => (
|
||||
// <NavLink
|
||||
// key={index}
|
||||
// to={item.route}
|
||||
// className={({ isActive }) =>
|
||||
// isActive ? "submenu-link active" : "submenu-link"
|
||||
// }
|
||||
// >
|
||||
// {item.label}
|
||||
// </NavLink>
|
||||
// ))}
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* <div className="topbar-submenu">
|
||||
// {activeSubmenu.map((item, index) => (
|
||||
// <NavLink
|
||||
// key={index}
|
||||
// to={item.route}
|
||||
// className={({ isActive }) =>
|
||||
// isActive ? "submenu-link active" : "submenu-link"
|
||||
// }
|
||||
// >
|
||||
// {item.label}
|
||||
// </NavLink>
|
||||
// ))}
|
||||
// </div> */}
|
||||
// </div>
|
||||
|
||||
// {/* Aquí va el contenido de la página */}
|
||||
// <div className="content">
|
||||
// <Outlet />
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
//}
|
||||
|
||||
|
||||
|
||||
//{ label: "Property", route: "/app/properties/:id" },
|
||||
|
||||
// import React from "react";
|
||||
// import { Outlet, useLocation, NavLink } from "react-router-dom";
|
||||
// import { menuConfig } from "../constants/menuConfig";
|
||||
// import { FaBell, FaCog } from "react-icons/fa";
|
||||
// import "../styles/Dashboard.css";
|
||||
|
||||
// export default function Layout() {
|
||||
// const location = useLocation();
|
||||
// const pathname = location.pathname;
|
||||
|
||||
// // Encuentra la sección activa
|
||||
// const activeSectionKey = Object.keys(menuConfig).find((key) =>
|
||||
// pathname.startsWith(menuConfig[key].baseRoute)
|
||||
// );
|
||||
// const activeSection = menuConfig[activeSectionKey];
|
||||
// const activeSubmenu = activeSection?.submenu || [];
|
||||
|
||||
// return (
|
||||
// <div className="dashboard-layout">
|
||||
// {/* SIDEBAR */}
|
||||
// <aside className="sidebar">
|
||||
// <nav>
|
||||
// {Object.entries(menuConfig).map(([key, section]) => (
|
||||
// <NavLink
|
||||
// key={key}
|
||||
// to={section.baseRoute}
|
||||
// className={({ isActive }) =>
|
||||
// isActive ? "menu-items a active" : "menu-items a"
|
||||
// }
|
||||
// >
|
||||
// {section.label}
|
||||
// </NavLink>
|
||||
// ))}
|
||||
// </nav>
|
||||
// </aside>
|
||||
|
||||
// {/* CONTENIDO PRINCIPAL */}
|
||||
// <div className="main-content">
|
||||
// {/* TOPBAR */}
|
||||
// <div className="topbar">
|
||||
// <div className="topbar-header">
|
||||
// <div className="topbar-title">{activeSection?.label}</div>
|
||||
// <div className="topbar-icons">
|
||||
// <FaBell className="topbar-icon" />
|
||||
// <FaCog className="topbar-icon" />
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="topbar-submenu">
|
||||
// {activeSubmenu.map((item, index) => (
|
||||
// <NavLink
|
||||
// key={index}
|
||||
// to={item.route}
|
||||
// className={({ isActive }) =>
|
||||
// isActive ? "submenu-link active" : "submenu-link"
|
||||
// }
|
||||
// >
|
||||
// {item.label}
|
||||
// </NavLink>
|
||||
// ))}
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
|
||||
// {/* CONTENIDO */}
|
||||
// <div className="content">
|
||||
// <Outlet />
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
|
||||
|
||||
// // src/components/Layout.jsx
|
||||
// import React, { useState } from "react";
|
||||
// import { Outlet, NavLink } from "react-router-dom";
|
||||
// import "../styles/Dashboard.css";
|
||||
|
||||
// export default function Layout() {
|
||||
// const [isSidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
// const toggleSidebar = () => {
|
||||
// setSidebarOpen(!isSidebarOpen);
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <div className="dashboard-layout">
|
||||
|
||||
// {/* Sidebar */}
|
||||
// {isSidebarOpen && (
|
||||
// <aside className="sidebar">
|
||||
// <nav>
|
||||
// <h1></h1>
|
||||
// <NavLink to="/app/dashboard">Dashboards</NavLink>
|
||||
// <NavLink to="/app/expenses-to-approve">Expenses to be approved</NavLink>
|
||||
// <NavLink to="/app/expenses">Expenses</NavLink>
|
||||
// <NavLink to="/app/inventory">Inventory</NavLink>
|
||||
// <NavLink to="/app/payroll">Payroll</NavLink>
|
||||
// <NavLink to="/app/hotel">Hotel</NavLink>
|
||||
// <NavLink to="/app/income">Income</NavLink>
|
||||
// <NavLink to="/app/employees">Employees</NavLink>
|
||||
// <NavLink to="/app/contracts">Contracts</NavLink>
|
||||
// <NavLink to="/app/payments">Payments</NavLink>
|
||||
// <NavLink to="/app/pending-approval">PendingApproval</NavLink>
|
||||
// </nav>
|
||||
// </aside>
|
||||
// )}
|
||||
|
||||
// {/* Contenedor principal */}
|
||||
// <div className="main-content">
|
||||
// {/* Topbar */}
|
||||
// <div className="topbar">
|
||||
// <button onClick={toggleSidebar} style={{ fontSize: "1.2rem", marginRight: "auto", background: "none", border: "none", color: "white", cursor: "pointer" }}>
|
||||
// ☰
|
||||
// </button>
|
||||
// <span >Dashboard</span> {/* Cambia esto dinámicamente si deseas */}
|
||||
// </div>
|
||||
|
||||
// {/* Contenido de cada página */}
|
||||
// <div className="content">
|
||||
// <Outlet />
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// <NavLink to="/app/users">Users</NavLink>
|
||||
426
frontend/Frontend-Hotel/src/components/Layout2.jsx
Normal file
426
frontend/Frontend-Hotel/src/components/Layout2.jsx
Normal file
@@ -0,0 +1,426 @@
|
||||
// Layout.jsx
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Outlet, NavLink, useLocation } from "react-router-dom";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import "../styles/Dashboard.css";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import { langContext } from "../context/LenguageContext";
|
||||
import { FaSignOutAlt } from "react-icons/fa";
|
||||
import { menuConfig } from "../constants/menuconfig";
|
||||
|
||||
export default function Layout() {
|
||||
const { toggleLang, lang } = useContext(langContext);
|
||||
const { user, logout } = useContext(AuthContext);
|
||||
console.log('user', user);
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate("/");
|
||||
};
|
||||
const menuConfigWithPermissions = Object.values(menuConfig).map(section => ({
|
||||
...section,
|
||||
hidden:
|
||||
section.label === "Dashboards" ? (user >= 1 && user <= 2 ? false : true) :
|
||||
section.label === "Expenses to be approved" ? (user === 1 || user === 2 ? false : true) :
|
||||
section.label === "Expenses" ? (user >= 1 && user <= 5 ? false : true) :
|
||||
section.label === "Inventory" ? (user >= 1 && user <= 5 ? false : true) :
|
||||
section.label === "Payroll" ? (user >= 1 && user <= 4 ? false : true) :
|
||||
section.label === "Hotel" ? (user === 1 ? false : true) :
|
||||
section.label === "Income" ? (user >= 1 && user <= 4 ? false : true) :
|
||||
section.label === "Housekeeper" ? (user === 6 ? false : true) :
|
||||
false,
|
||||
submenu: section.submenu?.map(item => ({
|
||||
...item,
|
||||
hidden: item.hidden ||
|
||||
(section.label === "Expenses" && user === 2 && item.label !== "Report" && item.label !== "Monthly Report" ? true : false) ||
|
||||
(section.label === "Payroll" && user === 2 && !["Report", "Attendance", "Employees", "Contracts"].includes(item.label) ? true : false) ||
|
||||
(section.label === "Expenses" && user === 5 && !["New Expense", "Purchase Entries", "New Suppliers"].includes(item.label) ? true : false)
|
||||
}))
|
||||
}));
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isSidebarOpen, setSidebarOpen] = useState(true);
|
||||
const toggleSidebar = () => setSidebarOpen(!isSidebarOpen);
|
||||
// const isDetailPage = /^\/app\/properties\/\d+$/.test(location.pathname);
|
||||
// Detectar páginas de detalle (para ocultar el submenú)
|
||||
const isDetailPage =
|
||||
/^\/app\/properties\/\d+$/.test(location.pathname) || // Propiedades (ya existente)
|
||||
/^\/app\/payroll\/contracts-detail(\/.*)?$/.test(location.pathname) || // Contract Detail
|
||||
/^\/app\/expenses\/detail(\/.*)?$/.test(location.pathname); // Otros detalles si los tienes
|
||||
|
||||
|
||||
const activeSection = menuConfigWithPermissions.find(section => {
|
||||
if (section.hidden) return false;
|
||||
|
||||
const matchesSubmenu = section.submenu.some(item => location.pathname.startsWith(item.route));
|
||||
const matchesBasePath = location.pathname.startsWith(section.basePath);
|
||||
|
||||
if (matchesSubmenu || matchesBasePath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (section.label === "Income" && location.pathname.startsWith("/app/edit-income-form")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (section.label === "Expenses" && (location.pathname.startsWith("/app/expenses/edit/") || location.pathname.startsWith("/app/expenses/") || location.pathname === "/app/new-monthly")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (section.label === "Inventory" && location.pathname.startsWith("/app/alter-product/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (section.label === "Payroll" && (location.pathname.startsWith("/app/payroll/employee/") || location.pathname.startsWith("/app/payroll/contract/") || location.pathname.startsWith("/app/payroll/edit/") || location.pathname.startsWith("/app/payroll/contracts-detail/"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (section.label === "Hotel" && location.pathname.startsWith("/app/properties/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const activeSubmenu = activeSection?.label === "Housekeeper"
|
||||
? [{ label: "Outcomes", spanish_label: "Salidas", route: "/app/housekeeper/outcomes" }]
|
||||
: activeSection?.submenu || [];
|
||||
|
||||
const isLandingPage = location.pathname === '/app' || !activeSection;
|
||||
|
||||
|
||||
return (
|
||||
<div className="dashboard-layout">
|
||||
{/* Sidebar */}
|
||||
{isSidebarOpen && (
|
||||
<aside className="sidebar">
|
||||
<nav>
|
||||
{/*sSolo se muestran secciones que no están ocultas */}
|
||||
{menuConfigWithPermissions
|
||||
.filter(section => !section.hidden)
|
||||
.map((section, index) => (
|
||||
<NavLink key={index} to={section.basePath}>
|
||||
{lang === "en" ? section.label : section.spanish_label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="main-content">
|
||||
{/* Topbar */}
|
||||
<div className="topbar">
|
||||
<div className="topbar-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
|
||||
{/* Botón + Título (alineados a la izquierda) */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<button onClick={toggleSidebar} className="sidebar-toggle-button">
|
||||
☰
|
||||
</button>
|
||||
|
||||
<div className="topbar-title">
|
||||
{lang === "en" ? activeSection?.label : activeSection?.spanish_label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Iconos a la derecha */}
|
||||
<div className="topbar-icons" style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<select
|
||||
|
||||
|
||||
className="language-select"
|
||||
onChange={toggleLang}
|
||||
>
|
||||
<option value="en">EN</option>
|
||||
<option value="es">ES</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
background: "transparent",
|
||||
color: "#ffffffff",
|
||||
border: "none",
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
>
|
||||
<FaSignOutAlt className="topbar-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submenú (solo si no es página de detalle) */}
|
||||
{!isDetailPage && (
|
||||
<div className="topbar-submenu">
|
||||
{activeSubmenu.filter(section => !section.hidden).map((item, index) => (
|
||||
<NavLink
|
||||
key={index}
|
||||
to={item.route}
|
||||
className={({ isActive }) =>
|
||||
isActive ? "submenu-link active" : "submenu-link"
|
||||
}
|
||||
>
|
||||
{lang === "en" ? item.label : item.spanish_label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Página actual */}
|
||||
<div className="content">
|
||||
{isLandingPage ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
gap: '2rem'
|
||||
}}>
|
||||
<img
|
||||
src="/logoHotel.png"
|
||||
alt="Hotel Logo"
|
||||
style={{
|
||||
maxWidth: '300px',
|
||||
width: '100%',
|
||||
height: 'auto'
|
||||
}}
|
||||
/>
|
||||
<p style={{
|
||||
fontSize: '1.25rem',
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
maxWidth: '600px',
|
||||
padding: '0 1rem'
|
||||
}}>
|
||||
{lang === "en"
|
||||
? "To get started, select an option from the menu on the left."
|
||||
: "Para comenzar, selecciona una opción del menú de la izquierda."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Outlet />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// // Layout.jsx
|
||||
// import React, { use, useContext, useState } from "react";
|
||||
// import { Outlet, NavLink, useLocation } from "react-router-dom";
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
// import { FaBell, FaCog } from "react-icons/fa";
|
||||
// import "../styles/Dashboard.css";
|
||||
// import { AuthContext } from "../context/AuthContext";
|
||||
// import { langContext } from "../context/LenguageContext";
|
||||
// import { FaSignOutAlt } from "react-icons/fa";
|
||||
|
||||
// export default function Layout() {
|
||||
// const { toggleLang } = useContext(langContext);
|
||||
// const { user, logout } = useContext(AuthContext);
|
||||
// const handleLogout = () => {
|
||||
// logout();
|
||||
// navigate("/");
|
||||
// };
|
||||
// // const {lang, setLang} = useContext(AuthContext);
|
||||
// // console.log(lang);
|
||||
// const menuConfig = [
|
||||
// {
|
||||
// label: "Dashboards",
|
||||
// basePath: "/app/income",
|
||||
// submenu: [
|
||||
// { label: "Income", route: "/app/income" },
|
||||
// // { label: "Expenses", route: "/app/expenses" },
|
||||
// // { label: "Cost per room", route: "/app/cost-per-room" },
|
||||
// // { label: "Budget", route: "/app/budget" },
|
||||
// ],
|
||||
// hidden: user === 1 ? false : true //Solo admin puede ver dashboards,
|
||||
// },
|
||||
// {
|
||||
// label: "Expenses to be approved",
|
||||
// basePath: "/app/pending-approval",
|
||||
// submenu: [
|
||||
// { label: "Pending approval", route: "/app/pending-approval" },
|
||||
// { label: "Approved", route: "/app/approved" },
|
||||
// { label: "Rejected", route: "/app/rejected" },
|
||||
// ],
|
||||
// hidden: user === 1 ? false : true
|
||||
// },
|
||||
// {
|
||||
// label: "Expenses",
|
||||
// basePath: "/app/report-expense",
|
||||
// submenu: [
|
||||
// { label: "Report", route: "/app/report-expense" },
|
||||
// { label: "New Expense", route: "/app/new-expense" },
|
||||
// { label: "Payments", route: "/app/payments" },
|
||||
// { label: "Monthly Payments", route: "/app/monthly-payments" },
|
||||
// { label: "New Monthly Payments", route: "/app/new-monthly" },
|
||||
// { label: "Purchase Entries", route: "/app/purchase-entries" },
|
||||
// ],
|
||||
// hidden: user >= 1 && user <= 3 ? false : true
|
||||
// },
|
||||
// {
|
||||
// label: "Inventory",
|
||||
// basePath: "/app/products",
|
||||
// submenu: [
|
||||
// { label: "Products", route: "/app/products", hidden: user === 5 ? true : false },
|
||||
// { label: "New Product", route: "/app/new-product", hidden: user === 5 ? true : false },
|
||||
// { label: "Report", route: "/app/inventory-report", hidden: user === 5 ? true : false },
|
||||
// { label: "Discard Product", route: "/app/discard-product", hidden: user === 5 ? true : false },
|
||||
// { label: "Adjustments", route: "/app/product-adjustments", hidden: user === 5 ? true : false }
|
||||
// ],
|
||||
// hidden: user >= 1 && user <= 4 ? false : true
|
||||
// },
|
||||
// {
|
||||
// label: "Payroll",
|
||||
// basePath: "/app/payroll",
|
||||
// submenu: [
|
||||
// { label: "Report", route: "/app/payroll" },
|
||||
// { label: "New Contract", route: "/app/payroll/NewPayRoll"},
|
||||
// { label: "Attendance", route: "/app/payroll/attendance" },
|
||||
// { label: "Employees", route: "/app/payroll/employees" },
|
||||
// { label: "New Employee", route: "/app/payroll/newemployee" },
|
||||
// ],
|
||||
// hidden: user >= 1 && user <= 3 ? false : true
|
||||
// },
|
||||
// {
|
||||
// label: "Hotel",
|
||||
// basePath: "/app/properties",
|
||||
// submenu: [
|
||||
// { label: "Properties", route: "/app/properties" },
|
||||
// ],
|
||||
// hidden: user === 1 ? false : true
|
||||
// },
|
||||
// //SECCIÓN "OCULTA" PARA SETTINGS
|
||||
// {
|
||||
// label: "Settings",
|
||||
// basePath: "/app/settings",
|
||||
// submenu: [
|
||||
// { label: "General", route: "/app/settings" },
|
||||
// { label: "Users", route: "/app/settings/users" },
|
||||
// { label: "Units", route: "/app/settings/roles" },
|
||||
// { label: "Room management", route: "/app/settings/room-management" },
|
||||
// ],
|
||||
// hidden: true, //etiqueta para ignorar en sidebar
|
||||
// },
|
||||
// ];
|
||||
|
||||
// const navigate = useNavigate();
|
||||
// const location = useLocation();
|
||||
// const [isSidebarOpen, setSidebarOpen] = useState(true);
|
||||
// const toggleSidebar = () => setSidebarOpen(!isSidebarOpen);
|
||||
// const isDetailPage = /^\/app\/properties\/\d+$/.test(location.pathname);
|
||||
|
||||
// //Identificar la sección activa, incluyendo settings
|
||||
// const activeSection = menuConfig.find(section =>
|
||||
// section.submenu.some(item => location.pathname.startsWith(item.route))
|
||||
// );
|
||||
|
||||
// const activeSubmenu = activeSection?.submenu || [];
|
||||
|
||||
|
||||
// return (
|
||||
// <div className="dashboard-layout">
|
||||
// {/* Sidebar */}
|
||||
// {isSidebarOpen && (
|
||||
// <aside className="sidebar">
|
||||
// <nav>
|
||||
// {/*sSolo se muestran secciones que no están ocultas */}
|
||||
// {menuConfig
|
||||
// .filter(section => !section.hidden)
|
||||
// .map((section, index) => (
|
||||
// <NavLink key={index} to={section.basePath}>
|
||||
// {section.label}
|
||||
// </NavLink>
|
||||
// ))}
|
||||
// </nav>
|
||||
// </aside>
|
||||
// )}
|
||||
|
||||
// {/* Main content */}
|
||||
// <div className="main-content">
|
||||
// {/* Topbar */}
|
||||
// <div className="topbar">
|
||||
// <div className="topbar-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
|
||||
// {/* Botón + Título (alineados a la izquierda) */}
|
||||
// <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
// <button onClick={toggleSidebar} className="sidebar-toggle-button">
|
||||
// ☰
|
||||
// </button>
|
||||
|
||||
// <div className="topbar-title">{activeSection?.label}</div>
|
||||
// </div>
|
||||
|
||||
|
||||
// {/* Iconos a la derecha */}
|
||||
// <div className="topbar-icons" style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
// <FaBell className="topbar-icon" />
|
||||
// <FaCog
|
||||
// className="topbar-icon cursor-pointer"
|
||||
// onClick={() => navigate("/app/settings")}
|
||||
// />
|
||||
// <select
|
||||
|
||||
|
||||
// className="language-select"
|
||||
// onChange={toggleLang}
|
||||
// >
|
||||
// <option value="en">EN</option>
|
||||
// <option value="es">ES</option>
|
||||
// </select>
|
||||
// <button
|
||||
// onClick={handleLogout}
|
||||
// style={{
|
||||
// display: "flex",
|
||||
// alignItems: "center",
|
||||
// gap: "6px",
|
||||
// background: "transparent",
|
||||
// color: "#ffffffff",
|
||||
// border: "none",
|
||||
// fontWeight: "bold",
|
||||
// cursor: "pointer"
|
||||
// }}
|
||||
// >
|
||||
// <FaSignOutAlt className="topbar-icon" />
|
||||
// </button>
|
||||
|
||||
|
||||
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {/* Submenú (solo si no es página de detalle) */}
|
||||
// {!isDetailPage && (
|
||||
// <div className="topbar-submenu">
|
||||
// {activeSubmenu.filter(section => !section.hidden).map((item, index) => (
|
||||
// <NavLink
|
||||
// key={index}
|
||||
// to={item.route}
|
||||
// className={({ isActive }) =>
|
||||
// isActive ? "submenu-link active" : "submenu-link"
|
||||
// }
|
||||
// >
|
||||
// {item.label}
|
||||
// </NavLink>
|
||||
// ))}
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
|
||||
|
||||
// {/* Página actual */}
|
||||
// <div className="content">
|
||||
// <Outlet />
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
@@ -0,0 +1,74 @@
|
||||
/* components/Modals/ConfirmationModal.css */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(122, 0, 41, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 6px;
|
||||
width: 450px;
|
||||
max-width: 95%;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.4);
|
||||
border: 5px solid #7a0029;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: white;
|
||||
background-color: #7a0029;
|
||||
padding: 15px 20px;
|
||||
border-radius: 5px 5px 0 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.modal-button {
|
||||
padding: 10px 25px;
|
||||
font-size: 18px;
|
||||
border-radius: 30px;
|
||||
border: 2px solid #7a0029;
|
||||
cursor: pointer;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.modal-button.yes {
|
||||
color: green;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.modal-button.no {
|
||||
color: #7a0029;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// components/Modals/ConfirmationModal.jsx
|
||||
import React from 'react';
|
||||
import './ConfirmationModal.css'; // Estilos separados
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
|
||||
export default function ConfirmationModal({ isOpen, statusType, onConfirm, onCancel }) {
|
||||
const { lang } = useContext(langContext);
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h3>{lang === "en" ? "Confirm the status change" : "Confirmar el cambio de estado"}</h3>
|
||||
<button className="close-button" onClick={onCancel}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p>{lang === "en" ? "Are you sure you received" : "¿Estás seguro de que recibiste"} "{statusType}"?</p>
|
||||
<div className="modal-buttons">
|
||||
<button className="modal-button yes" onClick={onConfirm}>{lang === "en" ? "YES" : "SÍ"}</button>
|
||||
<button className="modal-button no" onClick={onCancel}>{lang === "en" ? "NO" : "NO"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// components/Modals/ConfirmationModal.jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './ConfirmationModal.css'; // Estilos separados
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
|
||||
|
||||
export default function ConfirmationOutcome({ isOpen, onConfirm, onCancel, description, taxes, initialAmount, initialTaxId, isFixedPayment }) {
|
||||
/*const [formHousekepeer, setFormHousekepeer] = useState(null)/*/
|
||||
const { lang } = useContext(langContext);
|
||||
const [form, setForm] = useState({
|
||||
tax: '',
|
||||
amount: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setForm({
|
||||
tax: initialTaxId || '',
|
||||
amount: initialAmount || ''
|
||||
});
|
||||
}
|
||||
}, [isOpen, initialAmount, initialTaxId]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
//console.log(name, value);
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleConfirmClick = () => {
|
||||
// Envía los valores al padre (Outcomes.jsx)
|
||||
onConfirm(form.tax, form.amount);
|
||||
};
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<form onSubmit={handleConfirmClick}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h3>{lang === "en" ? "Confirm the status change" : "Confirmar el cambio de estado"}</h3>
|
||||
<button className="close-button" onClick={onCancel}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div>
|
||||
<p>{lang === "en" ? "Payment" : "Pago"}</p>
|
||||
<input name = "PCO" value={description} onChange={handleChange} disabled={true}></input>
|
||||
</div>
|
||||
<div>
|
||||
<p>{lang === "en" ? "Amount" : "Subtotal"}</p>
|
||||
<input type='number' required name = "amount" value={form.amount} onChange={handleChange} disabled={isFixedPayment}></input>
|
||||
</div>
|
||||
<div>
|
||||
<p>{lang === "en" ? "Tax" : "Impuesto"}</p>
|
||||
<select name = "tax" required value={form.tax} onChange={handleChange} disabled={isFixedPayment}>
|
||||
<option value="">{lang === "en" ? "Select a tax" : "Selecciona un impuesto"}</option>
|
||||
{taxes?.map(tax => (
|
||||
<option key={tax.id} value={tax.id}>{tax.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-buttons">
|
||||
<button className="modal-button yes" type='submit'>{lang === "en" ? "PAID" : "PAGAR"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// components/Modals/ConfirmationModal.jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './ConfirmationModal.css'; // Estilos separados
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
|
||||
|
||||
export default function ConfirmationOutcome({ isOpen, onConfirm, onCancel, formHousekepeer, idproduct, nameProduct, productStock }) {
|
||||
/*const [formHousekepeer, setFormHousekepeer] = useState(null)/*/
|
||||
const { lang } = useContext(langContext);
|
||||
if (!isOpen) return null;
|
||||
const [PCO, setProduct] = useState(idproduct);
|
||||
const [UCO, setUnits] = useState(null);
|
||||
const [HCO, setHousekeeper] = useState(null);
|
||||
const [form, setForm] = useState({
|
||||
PCO: idproduct || '',
|
||||
UCO: '',
|
||||
HCO: ''
|
||||
});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name === 'UCO') {
|
||||
const numericValue = Number(value);
|
||||
const maxStock = Number(productStock) || 0;
|
||||
if (value === '') {
|
||||
setForm((prev) => ({ ...prev, [name]: '' }));
|
||||
} else if (!isNaN(numericValue) && numericValue >= 0) {
|
||||
const cappedValue = numericValue > maxStock ? maxStock : numericValue;
|
||||
setForm((prev) => ({ ...prev, [name]: cappedValue.toString() }));
|
||||
}
|
||||
} else {
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmClick = () => {
|
||||
// Envía los valores al padre (Outcomes.jsx)
|
||||
onConfirm(form.PCO, form.UCO, form.HCO);
|
||||
};
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h3>{lang === "en" ? "Confirm the status change" : "Confirmar el cambio de estado"}</h3>
|
||||
<button className="close-button" onClick={onCancel}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div>
|
||||
<p>{lang === "en" ? "Product" : "Producto"}</p>
|
||||
<input
|
||||
name="PCO"
|
||||
value={nameProduct}
|
||||
onChange={handleChange}
|
||||
disabled={true}
|
||||
style={{ color: '#000', width: '100%', padding: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p>{lang === "en" ? "Units" : "Unidades"}</p>
|
||||
<input
|
||||
type='number'
|
||||
name="UCO"
|
||||
value={form.UCO}
|
||||
min={0}
|
||||
max={productStock ? Number(productStock) : undefined}
|
||||
disabled={!productStock || Number(productStock) <= 0}
|
||||
title={(!productStock || Number(productStock) <= 0) ? (lang === "es" ? "No se puede consumir cuando el stock es 0" : "Cannot consume when stock is 0") : (lang === "es" ? `Máximo disponible: ${productStock}` : `Maximum available: ${productStock}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === '-' || e.key === 'e' || e.key === 'E' || e.key === '+') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onChange={handleChange}
|
||||
style={{
|
||||
color: '#000',
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
backgroundColor: (!productStock || Number(productStock) <= 0) ? '#f0f0f0' : 'white',
|
||||
cursor: (!productStock || Number(productStock) <= 0) ? 'not-allowed' : 'text',
|
||||
opacity: (!productStock || Number(productStock) <= 0) ? 0.6 : 1
|
||||
}}
|
||||
/>
|
||||
{(!productStock || Number(productStock) <= 0) && (
|
||||
<p style={{ color: 'red', fontSize: '12px', marginTop: '5px', marginBottom: 0 }}>
|
||||
{lang === "es" ? "No hay stock disponible" : "There is no stock"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p>{lang === "en" ? "Housekeeper" : "Camarista"}</p>
|
||||
<select
|
||||
name="HCO"
|
||||
value={form.HCO}
|
||||
onChange={handleChange}
|
||||
style={{ color: '#000', width: '100%', padding: '8px' }}
|
||||
>
|
||||
<option value="">{lang === "en" ? "Select a Housekepeer" : "Selecciona una ama de llaves"}</option>
|
||||
{formHousekepeer && formHousekepeer.map(HK => (
|
||||
<option key={HK.rfc_employee} value={HK.rfc_employee}>{HK.name_emp}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-buttons">
|
||||
<button
|
||||
className="modal-button yes"
|
||||
onClick={handleConfirmClick}
|
||||
disabled={!productStock || Number(productStock) <= 0}
|
||||
style={{
|
||||
opacity: (!productStock || Number(productStock) <= 0) ? 0.5 : 1,
|
||||
cursor: (!productStock || Number(productStock) <= 0) ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{lang === "en" ? "SAVE" : "GUARDAR"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
.discard-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.discard-modal-box {
|
||||
background: white;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
width: 450px;
|
||||
max-width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
animation: slideUp 0.3s ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.discard-modal-header {
|
||||
background-color: #7a0029;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.discard-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.discard-modal-body {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #213547;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.discard-modal-body p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.discard-modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 20px 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.discard-modal-button {
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.discard-modal-button-cancel {
|
||||
background-color: #e2e8f0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.discard-modal-button-cancel:hover {
|
||||
background-color: #cbd5e0;
|
||||
}
|
||||
|
||||
.discard-modal-button-confirm {
|
||||
background-color: #7a0029;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.discard-modal-button-confirm:hover {
|
||||
background-color: #5a001f;
|
||||
}
|
||||
|
||||
.discard-modal-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
import './DiscardConfirmModal.css';
|
||||
|
||||
export default function DiscardConfirmModal({ isOpen, message, onConfirm, onCancel }) {
|
||||
const { lang } = useContext(langContext);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="discard-modal-overlay" onClick={onCancel}>
|
||||
<div className="discard-modal-box" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="discard-modal-header">
|
||||
<h3>{lang === "es" ? "Confirmar descarte" : "Confirm Discard"}</h3>
|
||||
</div>
|
||||
<div className="discard-modal-body">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
<div className="discard-modal-actions">
|
||||
<button className="discard-modal-button discard-modal-button-cancel" onClick={onCancel}>
|
||||
{lang === "es" ? "Cancelar" : "Cancel"}
|
||||
</button>
|
||||
<button className="discard-modal-button discard-modal-button-confirm" onClick={onConfirm}>
|
||||
{lang === "es" ? "Confirmar" : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
57
frontend/Frontend-Hotel/src/components/Modals/Modal.css
Normal file
57
frontend/Frontend-Hotel/src/components/Modals/Modal.css
Normal file
@@ -0,0 +1,57 @@
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
width: 350px;
|
||||
max-width: 90%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.modal-box h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
color: #111;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 15px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #f1f1f1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background-color: #8b0000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn--primary:hover {
|
||||
background-color: #a00000;
|
||||
}
|
||||
22
frontend/Frontend-Hotel/src/components/Modals/Modal.jsx
Normal file
22
frontend/Frontend-Hotel/src/components/Modals/Modal.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React, { useState } from 'react';
|
||||
import './Modal.css'; // Asegúrate de tener el estilo del modal
|
||||
|
||||
export default function Modal({ isOpen, closeModal }) {
|
||||
return (
|
||||
isOpen && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-box">
|
||||
<h3>Enter your email address and we'll send a new password to your email.</h3>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
className="modal-input"
|
||||
/>
|
||||
<button onClick={closeModal} className="btn btn--primary">
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
53
frontend/Frontend-Hotel/src/components/Navbar/Navbar.css
Normal file
53
frontend/Frontend-Hotel/src/components/Navbar/Navbar.css
Normal file
@@ -0,0 +1,53 @@
|
||||
z/* src/styles/Navbar.css */
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #4a0d0d; /* Marrón oscuro */
|
||||
padding: 12px 20px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.navbar__brand {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.navbar__nav {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.nav__link {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.nav__link.active {
|
||||
border-bottom: 2px solid #f8d47b; /* Amarillo suave */
|
||||
}
|
||||
|
||||
.navbar__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background-color: #f8d47b; /* Amarillo */
|
||||
color: #4a0d0d;
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background-color: #ddd;
|
||||
color: #333;
|
||||
}
|
||||
33
frontend/Frontend-Hotel/src/components/Navbar/Navbar.jsx
Normal file
33
frontend/Frontend-Hotel/src/components/Navbar/Navbar.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
// src/components/Navbar.jsx
|
||||
import { NavLink, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../../context/AuthContext.jsx";
|
||||
import "./Navbar.css"; // estilos separados
|
||||
|
||||
export default function Navbar() {
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate("/"); // 👈 al cerrar sesión vuelve al Login
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="navbar">
|
||||
<div className="navbar__brand"></div>
|
||||
|
||||
<div className="navbar__actions">
|
||||
{isAuthenticated ? (
|
||||
<button className="btn btn--secondary" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
) : (
|
||||
<NavLink to="/" className="btn btn--primary">
|
||||
Login
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
53
frontend/Frontend-Hotel/src/components/Sidebar.jsx
Normal file
53
frontend/Frontend-Hotel/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// Sidebar.jsx
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { menuConfig } from "../constants/menuConfig";
|
||||
|
||||
const Sidebar = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<nav>
|
||||
{Object.entries(menuConfig).map(([key, section]) => (
|
||||
<button
|
||||
key={key}
|
||||
className="sidebar-link"
|
||||
onClick={() => navigate(section.basePath)}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
// import React, { useState } from "react";
|
||||
// import { NavLink } from "react-router-dom";
|
||||
// import "./..styles/Sidebar.css";
|
||||
|
||||
// export default function Sidebar() {
|
||||
// const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// return (
|
||||
// <div className={`sidebar ${collapsed ? "collapsed" : ""}`}>
|
||||
// <div className="sidebar-header">
|
||||
// <button className="toggle-btn" onClick={() => setCollapsed(!collapsed)}>
|
||||
// ☰
|
||||
// </button>
|
||||
// {!collapsed && <span className="title">Dashboard</span>}
|
||||
// </div>
|
||||
|
||||
// <nav className="sidebar-nav">
|
||||
// <NavLink to="/app/dashboard">Dashboards</NavLink>
|
||||
// <NavLink to="/app/expenses-to-approve">Expenses to be approved</NavLink>
|
||||
// <NavLink to="/app/expenses">Expenses</NavLink>
|
||||
// <NavLink to="/app/inventory">Inventory</NavLink>
|
||||
// <NavLink to="/app/payroll">Payroll</NavLink>
|
||||
// <NavLink to="/app/hotel">Hotel</NavLink>
|
||||
// </nav>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
100
frontend/Frontend-Hotel/src/components/SummaryCard.css
Normal file
100
frontend/Frontend-Hotel/src/components/SummaryCard.css
Normal file
@@ -0,0 +1,100 @@
|
||||
.summary-card-enhanced {
|
||||
background: linear-gradient(145deg, #ffffff, #f8f9fa);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
min-width: 180px;
|
||||
flex: 1;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.summary-card-enhanced:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.summary-card-enhanced.primary {
|
||||
background: linear-gradient(145deg, #ffffff, #f8f9fa);
|
||||
color: #333;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.card-amount {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin: 8px 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.card-percentage {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #28a745;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||
border-top-color: #fcd200;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.summary-card-enhanced {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.card-amount {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.summary-card-enhanced {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.card-amount {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
31
frontend/Frontend-Hotel/src/components/SummaryCard.jsx
Normal file
31
frontend/Frontend-Hotel/src/components/SummaryCard.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import './SummaryCard.css';
|
||||
|
||||
const SummaryCard = ({
|
||||
title,
|
||||
amount,
|
||||
isLoading = false,
|
||||
isPrimary = false
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`summary-card-enhanced ${isPrimary ? 'primary' : ''}`}>
|
||||
<div className="card-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`summary-card-enhanced ${isPrimary ? 'primary' : ''}`}>
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{title}</h3>
|
||||
</div>
|
||||
<div className="card-amount">${amount || 0}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SummaryCard;
|
||||
|
||||
28
frontend/Frontend-Hotel/src/components/Switch.jsx
Normal file
28
frontend/Frontend-Hotel/src/components/Switch.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
const Switch = ({ checked, onChange, disabled = false }) => {
|
||||
return (
|
||||
<label className="inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div
|
||||
className={`relative inline-block w-10 h-6 transition duration-200 ease-in-out rounded-full ${
|
||||
checked ? 'bg-green-500' : 'bg-gray-300'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform duration-200 ease-in-out ${
|
||||
checked ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default Switch;
|
||||
152
frontend/Frontend-Hotel/src/components/Table/HotelTable copy.jsx
Normal file
152
frontend/Frontend-Hotel/src/components/Table/HotelTable copy.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FaExternalLinkAlt } from 'react-icons/fa';
|
||||
import './Table.css';
|
||||
|
||||
export default function Table({ columns, data }) {
|
||||
return (
|
||||
<table className="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col.key}>{col.header}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{columns.map((col, colIndex) => (
|
||||
<td key={colIndex}>
|
||||
{col.render
|
||||
? col.render(row[col.key], row, rowIndex)
|
||||
: col.key === 'propertyId'
|
||||
? (
|
||||
<Link
|
||||
to={`/app/properties/${row[col.key]}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
textDecoration: 'none',
|
||||
color: '#003366',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{row[col.key]}
|
||||
<FaExternalLinkAlt size={12} />
|
||||
</Link>
|
||||
)
|
||||
: row[col.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// import React from 'react';
|
||||
// import { Link } from 'react-router-dom';
|
||||
// import { FaArrowRight } from 'react-icons/fa';
|
||||
// import './Table.css';
|
||||
|
||||
// export default function Table({ columns, data }) {
|
||||
// return (
|
||||
// <table className="custom-table">
|
||||
// <thead>
|
||||
// <tr>
|
||||
// {columns.map((col) => (
|
||||
// <th key={col.key}>{col.header}</th>
|
||||
// ))}
|
||||
// </tr>
|
||||
// </thead>
|
||||
// <tbody>
|
||||
// {data.map((row, rowIndex) => (
|
||||
// <tr key={rowIndex}>
|
||||
// {columns.map((col, colIndex) => (
|
||||
// <td key={colIndex}>
|
||||
// {col.render
|
||||
// ? col.render(row[col.key], row)
|
||||
// : col.key === 'propertyId'
|
||||
// ? (
|
||||
// <Link to={`/app/properties/${row[col.key]}`} style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
// {row[col.key]}
|
||||
// <FaArrowRight size={12} />
|
||||
// </Link>
|
||||
// )
|
||||
// : row[col.key]}
|
||||
// </td>
|
||||
// ))}
|
||||
// </tr>
|
||||
// ))}
|
||||
// </tbody>
|
||||
// </table>
|
||||
// );
|
||||
// }
|
||||
|
||||
// import React from 'react';
|
||||
// import { Link } from 'react-router-dom';
|
||||
// import './Table.css';
|
||||
|
||||
// export default function Table({ columns, data }) {
|
||||
// return (
|
||||
// <table className="custom-table">
|
||||
// <thead>
|
||||
// <tr>
|
||||
// {columns.map((col) => (
|
||||
// <th key={col.key}>{col.header}</th>
|
||||
// ))}
|
||||
// </tr>
|
||||
// </thead>
|
||||
// <tbody>
|
||||
// {data.map((row, rowIndex) => (
|
||||
// <tr key={rowIndex}>
|
||||
// {columns.map((col, colIndex) => (
|
||||
// <td key={colIndex}>
|
||||
// {col.render
|
||||
// ? col.render(row[col.key], row) // usar render si está definido
|
||||
// : col.key === 'propertyId'
|
||||
// ? <Link to={`/app/properties/${row[col.key]}`}>{row[col.key]}</Link>
|
||||
// : row[col.key]}
|
||||
// </td>
|
||||
// ))}
|
||||
// </tr>
|
||||
// ))}
|
||||
// </tbody>
|
||||
// </table>
|
||||
// );
|
||||
// }
|
||||
|
||||
|
||||
// import React from 'react';
|
||||
// import './Table.css';
|
||||
|
||||
// export default function Table({ columns, data }) {
|
||||
// return (
|
||||
// <table className="custom-table">
|
||||
// <thead>
|
||||
// <tr>
|
||||
// {columns.map((col) => (
|
||||
// <th key={col.key}>{col.header}</th>
|
||||
// ))}
|
||||
// </tr>
|
||||
// </thead>
|
||||
// <tbody>
|
||||
// {data.map((row, rowIndex) => (
|
||||
// <tr key={rowIndex}>
|
||||
// {columns.map((col, colIndex) => (
|
||||
// <td key={colIndex}>
|
||||
// {col.render
|
||||
// ? col.render(row[col.key], row) // usar render si está definido
|
||||
// : row[col.key]}
|
||||
// </td>
|
||||
// ))}
|
||||
// </tr>
|
||||
// ))}
|
||||
// </tbody>
|
||||
|
||||
// </table>
|
||||
// );
|
||||
// }
|
||||
152
frontend/Frontend-Hotel/src/components/Table/HotelTable.jsx
Normal file
152
frontend/Frontend-Hotel/src/components/Table/HotelTable.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FaExternalLinkAlt } from 'react-icons/fa';
|
||||
import './Table.css';
|
||||
|
||||
export default function Table({ columns, data }) {
|
||||
return (
|
||||
<table className="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} style={col.headerStyle || {}}>{col.header}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{columns.map((col, colIndex) => (
|
||||
<td key={colIndex}>
|
||||
{col.render
|
||||
? col.render(row[col.key], row)
|
||||
: col.key === 'propertyId'
|
||||
? (
|
||||
<Link
|
||||
to={`/app/properties/${row[col.key]}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
textDecoration: 'none',
|
||||
color: '#003366',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{row[col.key]}
|
||||
<FaExternalLinkAlt size={12} />
|
||||
</Link>
|
||||
)
|
||||
: row[col.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// import React from 'react';
|
||||
// import { Link } from 'react-router-dom';
|
||||
// import { FaArrowRight } from 'react-icons/fa';
|
||||
// import './Table.css';
|
||||
|
||||
// export default function Table({ columns, data }) {
|
||||
// return (
|
||||
// <table className="custom-table">
|
||||
// <thead>
|
||||
// <tr>
|
||||
// {columns.map((col) => (
|
||||
// <th key={col.key}>{col.header}</th>
|
||||
// ))}
|
||||
// </tr>
|
||||
// </thead>
|
||||
// <tbody>
|
||||
// {data.map((row, rowIndex) => (
|
||||
// <tr key={rowIndex}>
|
||||
// {columns.map((col, colIndex) => (
|
||||
// <td key={colIndex}>
|
||||
// {col.render
|
||||
// ? col.render(row[col.key], row)
|
||||
// : col.key === 'propertyId'
|
||||
// ? (
|
||||
// <Link to={`/app/properties/${row[col.key]}`} style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
// {row[col.key]}
|
||||
// <FaArrowRight size={12} />
|
||||
// </Link>
|
||||
// )
|
||||
// : row[col.key]}
|
||||
// </td>
|
||||
// ))}
|
||||
// </tr>
|
||||
// ))}
|
||||
// </tbody>
|
||||
// </table>
|
||||
// );
|
||||
// }
|
||||
|
||||
// import React from 'react';
|
||||
// import { Link } from 'react-router-dom';
|
||||
// import './Table.css';
|
||||
|
||||
// export default function Table({ columns, data }) {
|
||||
// return (
|
||||
// <table className="custom-table">
|
||||
// <thead>
|
||||
// <tr>
|
||||
// {columns.map((col) => (
|
||||
// <th key={col.key}>{col.header}</th>
|
||||
// ))}
|
||||
// </tr>
|
||||
// </thead>
|
||||
// <tbody>
|
||||
// {data.map((row, rowIndex) => (
|
||||
// <tr key={rowIndex}>
|
||||
// {columns.map((col, colIndex) => (
|
||||
// <td key={colIndex}>
|
||||
// {col.render
|
||||
// ? col.render(row[col.key], row) // usar render si está definido
|
||||
// : col.key === 'propertyId'
|
||||
// ? <Link to={`/app/properties/${row[col.key]}`}>{row[col.key]}</Link>
|
||||
// : row[col.key]}
|
||||
// </td>
|
||||
// ))}
|
||||
// </tr>
|
||||
// ))}
|
||||
// </tbody>
|
||||
// </table>
|
||||
// );
|
||||
// }
|
||||
|
||||
|
||||
// import React from 'react';
|
||||
// import './Table.css';
|
||||
|
||||
// export default function Table({ columns, data }) {
|
||||
// return (
|
||||
// <table className="custom-table">
|
||||
// <thead>
|
||||
// <tr>
|
||||
// {columns.map((col) => (
|
||||
// <th key={col.key}>{col.header}</th>
|
||||
// ))}
|
||||
// </tr>
|
||||
// </thead>
|
||||
// <tbody>
|
||||
// {data.map((row, rowIndex) => (
|
||||
// <tr key={rowIndex}>
|
||||
// {columns.map((col, colIndex) => (
|
||||
// <td key={colIndex}>
|
||||
// {col.render
|
||||
// ? col.render(row[col.key], row) // usar render si está definido
|
||||
// : row[col.key]}
|
||||
// </td>
|
||||
// ))}
|
||||
// </tr>
|
||||
// ))}
|
||||
// </tbody>
|
||||
|
||||
// </table>
|
||||
// );
|
||||
// }
|
||||
184
frontend/Frontend-Hotel/src/components/Table/Table.css
Normal file
184
frontend/Frontend-Hotel/src/components/Table/Table.css
Normal file
@@ -0,0 +1,184 @@
|
||||
/* .custom-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family:'Trebuchet MS', 'Lucida Sans Unicode', 'Lucida Grande', 'Lucida Sans', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.custom-table thead tr {
|
||||
background-color: #7b001a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.custom-table th,
|
||||
.custom-table td {
|
||||
padding: 10px 15px;
|
||||
border: 2px solid #ffcc00;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.custom-table tbody tr {
|
||||
background-color: #ffffff;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 5px 10px;
|
||||
border-radius: 14px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
color: green;
|
||||
border: 1px solid green;
|
||||
background-color: rgba(0, 128, 0, 0.192);
|
||||
}
|
||||
|
||||
.status-badge.reject {
|
||||
color: red;
|
||||
border: 1px solid rgb(190, 4, 4);
|
||||
background-color: rgba(255, 0, 0, 0.171);
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
color: rgb(128, 83, 0);
|
||||
border: 1px solid rgb(235, 158, 15);
|
||||
background-color: rgba(248, 176, 42, 0.76);
|
||||
}
|
||||
|
||||
.status-button.approve {
|
||||
background-color: #009e2db0;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 12px;
|
||||
border-radius: 2px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-button.reject {
|
||||
background-color: #d80404b0;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 12px;
|
||||
border-radius: 2px;
|
||||
font-weight: bold;
|
||||
} */
|
||||
|
||||
.custom-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
/* font-family:'Trebuchet MS', 'Lucida Sans Unicode', 'Lucida Grande', 'Lucida Sans', Arial, sans-serif; */
|
||||
font-family: 'Franklin Gothic', 'Arial Narrow', Monserrat, sans-serif
|
||||
}
|
||||
|
||||
.custom-table thead tr {
|
||||
background-color: #7b001a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/*Bordes de línea*/
|
||||
.custom-table th,
|
||||
.custom-table td {
|
||||
padding: 10px 15px;
|
||||
border: 2px solid #ffcc00;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.custom-table tbody tr {
|
||||
font-family: montserrat;
|
||||
background-color: #ffffff;
|
||||
color: #515151;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 5px 10px;
|
||||
border-radius: 14px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
color: green;
|
||||
border: 1px solid green;
|
||||
background-color: rgba(0, 128, 0, 0.192);
|
||||
}
|
||||
|
||||
.status-badge.reject {
|
||||
color: red;
|
||||
border: 1px solid rgb(190, 4, 4);
|
||||
background-color: rgba(255, 0, 0, 0.171);
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
color: rgb(128, 83, 0);
|
||||
border: 1px solid rgb(235, 158, 15);
|
||||
background-color: rgba(248, 176, 42, 0.76);
|
||||
}
|
||||
|
||||
.status-badge.paid {
|
||||
color: green;
|
||||
border: 1px solid green;
|
||||
background-color: rgba(0, 128, 0, 0.192);
|
||||
}
|
||||
|
||||
.status-button.approve {
|
||||
background-color: #009e2db0;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 12px;
|
||||
border-radius: 2px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-button.reject {
|
||||
background-color: #d80404b0;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 12px;
|
||||
border-radius: 2px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-button.pending {
|
||||
background-color: #e2c000;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 12px;
|
||||
border-radius: 2px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-button.paid {
|
||||
background-color: #009e2db0;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 12px;
|
||||
border-radius: 2px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
background-color: transparent;
|
||||
color: #7b001a;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/*Icono de flecha*/
|
||||
|
||||
.custom-table a:hover {
|
||||
color: #0055aa;
|
||||
}
|
||||
|
||||
.custom-table a:hover svg {
|
||||
transform: translateX(2px);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
8
frontend/Frontend-Hotel/src/components/topbar/Topbar.css
Normal file
8
frontend/Frontend-Hotel/src/components/topbar/Topbar.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/* .topbar {
|
||||
background-color: #e0b200;
|
||||
padding: 1rem;
|
||||
color: #0f0d75;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
} */
|
||||
13
frontend/Frontend-Hotel/src/components/topbar/Topbar.jsx
Normal file
13
frontend/Frontend-Hotel/src/components/topbar/Topbar.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
// src/components/Topbar.jsx
|
||||
import React from "react";
|
||||
import "./Topbar.css";
|
||||
|
||||
const Topbar = () => {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<h1 className="logo">Hacienda San Ángel</h1>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Topbar;
|
||||
66
frontend/Frontend-Hotel/src/constants/menuConfig.js
Normal file
66
frontend/Frontend-Hotel/src/constants/menuConfig.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// src/constants/menuConfig.js
|
||||
|
||||
export const menuConfig = {
|
||||
dashboards: {
|
||||
label: "Dashboards",
|
||||
basePath: "/app/income",
|
||||
submenu: [
|
||||
{ label: "Income", route: "/app/income" },
|
||||
{ label: "Hotel P&L", route: "/app/hotelpl" },
|
||||
{ label: "P&L Restaurant", route: "/app/restaurantpl" },
|
||||
{ label: "Room Analysis", route: "/app/cost-per-room" },
|
||||
{ label: "Budget", route: "/app/budget" },
|
||||
{ label: "Expenses", route: "/app/expenses" },
|
||||
],
|
||||
},
|
||||
income: {
|
||||
label: "Income",
|
||||
basePath: "/app/new-income-report",
|
||||
submenu: [
|
||||
{ label: "Report", route: "/app/new-income-report" },
|
||||
{ label: "New Income", route: "/app/new-income-form" },
|
||||
],
|
||||
},
|
||||
expensesToApprove: {
|
||||
label: "Expenses to be approved",
|
||||
basePath: "/app/pending-approval",
|
||||
submenu: [
|
||||
{ label: "Pending approval", route: "/app/pending-approval" },
|
||||
{ label: "Approved", route: "/app/approved" },
|
||||
{ label: "Rejected", route: "/app/rejected" },
|
||||
],
|
||||
},
|
||||
expenses: {
|
||||
label: "Expenses",
|
||||
basePath: "/app/report-expense",
|
||||
submenu: [],
|
||||
},
|
||||
inventory: {
|
||||
label: "Inventory",
|
||||
basePath: "/app/products",
|
||||
submenu: [
|
||||
{ label: "Products", route: "/app/products" },
|
||||
{ label: "New Product", route: "/app/new-product" },
|
||||
{ label: "Report", route: "/app/inventory-report" },
|
||||
{ label: "Discard Product", route: "/app/discard-product" },
|
||||
{ label: "Adjustments", route: "/app/product-adjustments" },
|
||||
{ label: "Outcomes", route: "/app/inventory/outcomes" }
|
||||
],
|
||||
},
|
||||
payroll: {
|
||||
label: "Payroll",
|
||||
basePath: "/app/payroll",
|
||||
submenu: [
|
||||
{ label: "Report", route: "/app/payroll" },
|
||||
{ label: "Attendance", route: "/app/payroll/attendance" },
|
||||
{ label: "Employees", route: "/app/payroll/employees" },
|
||||
{ label: "New Employee", route: "/app/payroll/newemployee" },
|
||||
{ label: "Contracts", route: "/app/payroll/contract" }
|
||||
],
|
||||
},
|
||||
hotel: {
|
||||
label: "Hotel",
|
||||
basePath: "/app/hotel",
|
||||
submenu: [],
|
||||
},
|
||||
};
|
||||
90
frontend/Frontend-Hotel/src/constants/menuconfig.js
Normal file
90
frontend/Frontend-Hotel/src/constants/menuconfig.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// src/constants/menuConfig.js
|
||||
|
||||
export const menuConfig = {
|
||||
dashboards: {
|
||||
label: "Dashboards",
|
||||
spanish_label: "Tableros",
|
||||
basePath: "/app/income",
|
||||
submenu: [
|
||||
{ label: "Little Hotelier", spanish_label: "Little Hotelier", route: "/app/income" },
|
||||
{ label: "Hotel P&L", spanish_label: "P&L Hotel", route: "/app/hotelpl" },
|
||||
{ label: "Room Analysis", spanish_label: "Análisis de Habitaciones", route: "/app/room-analysis" },
|
||||
{ label: "P&L Restaurant", spanish_label: "P&L Restaurante", route: "/app/restaurantpl" },
|
||||
{ label: "Restaurant Analysis", spanish_label: "Análisis de Restaurante", route: "/app/restaurant-analysis" },
|
||||
// { label: "Budget", spanish_label: "Presupuesto", route: "/app/budget" },
|
||||
// { label: "Expenses", spanish_label: "Gastos", route: "/app/expenses" },
|
||||
],
|
||||
},
|
||||
housekeeper: {
|
||||
label: "Housekeeper",
|
||||
spanish_label: "Cuidador de Habitaciones",
|
||||
basePath: "/app/housekeeper/outcomes",
|
||||
submenu: [],
|
||||
},
|
||||
income: {
|
||||
label: "Income",
|
||||
spanish_label: "Ingresos",
|
||||
basePath: "/app/new-income-report",
|
||||
submenu: [
|
||||
{ label: "Report", spanish_label: "Reporte", route: "/app/new-income-report" },
|
||||
{ label: "New Income", spanish_label: "Nuevo Ingreso", route: "/app/new-income-form" },
|
||||
],
|
||||
},
|
||||
expensesToApprove: {
|
||||
label: "Expenses to be approved",
|
||||
spanish_label: "Gastos por aprobar",
|
||||
basePath: "/app/pending-approval",
|
||||
submenu: [
|
||||
{ label: "Pending approval", spanish_label: "Pendientes de aprobación", route: "/app/pending-approval" },
|
||||
{ label: "Approved", spanish_label: "Aprobados", route: "/app/approved" },
|
||||
{ label: "Rejected", spanish_label: "Rechazados", route: "/app/rejected" },
|
||||
],
|
||||
},
|
||||
expenses: {
|
||||
label: "Expenses",
|
||||
spanish_label: "Gastos",
|
||||
basePath: "/app/report-expense",
|
||||
submenu: [
|
||||
{ label: "Report", spanish_label: "Reporte", route: "/app/report-expense" },
|
||||
{ label: "New Expense", spanish_label: "Nuevo Gasto", route: "/app/new-expense" },
|
||||
{ label: "Payments", spanish_label: "Pagos", route: "/app/payments" },
|
||||
{ label: "Monthly Report", spanish_label: "Reporte Mensual", route: "/app/monthly-report" },
|
||||
{ label: "Monthly Payments", spanish_label: "Pagos Mensuales", route: "/app/monthly-payments" },
|
||||
{ label: "New Monthly Payments", spanish_label: "Nuevos Pagos Mensuales", route: "/app/new-monthly", hidden: true },
|
||||
{ label: "Purchase Entries", spanish_label: "Entradas de Compra", route: "/app/purchase-entries" },
|
||||
{ label: "New Suppliers", spanish_label: "Nuevos Proveedores", route: "/app/new-suppliers" },
|
||||
],
|
||||
},
|
||||
inventory: {
|
||||
label: "Inventory",
|
||||
spanish_label: "Inventario",
|
||||
basePath: "/app/products",
|
||||
submenu: [
|
||||
{ label: "Products", spanish_label: "Productos", route: "/app/products" },
|
||||
{ label: "New Product", spanish_label: "Nuevo Producto", route: "/app/new-product" },
|
||||
{ label: "Report", spanish_label: "Reporte", route: "/app/inventory-report" },
|
||||
{ label: "Discard Product", spanish_label: "Descartar Producto", route: "/app/discard-product" },
|
||||
{ label: "Adjustments", spanish_label: "Ajustes", route: "/app/product-adjustments" },
|
||||
{ label: "Outcomes", spanish_label: "Salidas", route: "/app/inventory/outcomes" }
|
||||
],
|
||||
},
|
||||
payroll: {
|
||||
label: "Payroll",
|
||||
spanish_label: "Nómina",
|
||||
basePath: "/app/payroll",
|
||||
submenu: [
|
||||
{ label: "Report", spanish_label: "Reporte", route: "/app/payroll" },
|
||||
{ label: "New Contract", spanish_label: "Nuevo Contrato", route: "/app/payroll/newcontract" },
|
||||
{ label: "Attendance", spanish_label: "Asistencia", route: "/app/payroll/attendance" },
|
||||
{ label: "Employees", spanish_label: "Empleados", route: "/app/payroll/employees" },
|
||||
{ label: "New Employee", spanish_label: "Nuevo Empleado", route: "/app/payroll/newemployee" },
|
||||
{ label: "Contracts", spanish_label: "Contratos", route: "/app/payroll/contract" }
|
||||
],
|
||||
},
|
||||
// hotel: {
|
||||
// label: "Hotel",
|
||||
// spanish_label: "Hotel",
|
||||
// basePath: "/app/hotel",
|
||||
// submenu: [],
|
||||
// },
|
||||
};
|
||||
85
frontend/Frontend-Hotel/src/context/AuthContext.jsx
Normal file
85
frontend/Frontend-Hotel/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
// context/AuthContext.jsx
|
||||
import { createContext, useState, useEffect } from "react";
|
||||
|
||||
export const AuthContext = createContext();
|
||||
|
||||
function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null); // { id, name, role }
|
||||
const [userData, setUserData] = useState(null);
|
||||
|
||||
// Recuperar user de localStorage al iniciar
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem("rol");
|
||||
if (savedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(savedUser));
|
||||
} catch (error) {
|
||||
console.error("Error parsing user data:", error);
|
||||
localStorage.removeItem("rol");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = (userData, data) => {
|
||||
setUserData(data);
|
||||
setUser(userData);
|
||||
localStorage.setItem("rol", JSON.stringify(userData));
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
localStorage.removeItem("rol");
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, userData }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { AuthProvider };
|
||||
|
||||
// // context/AuthContext.jsx
|
||||
// import { createContext, useState, useEffect } from "react";
|
||||
|
||||
// export const AuthContext = createContext();
|
||||
|
||||
// export const AuthProvider = ({ children }) => {
|
||||
// const [user, setUser] = useState(null); // { id, name, role }
|
||||
// const [lang, setLang] = useState("es");//true = español
|
||||
|
||||
// // useEffect(() => {
|
||||
// // const savedUser = localStorage.getItem("rol");
|
||||
// // if (savedUser && savedUser !== "undefined") {
|
||||
// // try {
|
||||
// // setUser(JSON.parse(savedUser));
|
||||
// // } catch (error) {
|
||||
// // console.error("Error al parsear el usuario:", error);
|
||||
// // localStorage.removeItem("rol"); // limpia el valor corrupto
|
||||
// // }
|
||||
// // }
|
||||
// // }, []);
|
||||
|
||||
// // Recuperar user de localStorage al iniciar
|
||||
// useEffect(() => {
|
||||
// const savedUser = localStorage.getItem("rol");
|
||||
// if (savedUser) setUser(JSON.parse(savedUser));
|
||||
// }, []);
|
||||
|
||||
// const login = (userData) => {
|
||||
// setUser(userData);
|
||||
// localStorage.setItem("rol", JSON.stringify(userData));
|
||||
// };
|
||||
|
||||
// const logout = () => {
|
||||
// setUser(null);
|
||||
// localStorage.removeItem("rol");
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <AuthContext.Provider value={{ user, login, logout,lang ,setLang}}>
|
||||
// {children}
|
||||
// </AuthContext.Provider>
|
||||
// );
|
||||
// };
|
||||
24
frontend/Frontend-Hotel/src/context/LenguageContext.jsx
Normal file
24
frontend/Frontend-Hotel/src/context/LenguageContext.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createContext, useState, useEffect, useContext } from "react";
|
||||
|
||||
export const langContext = createContext();
|
||||
|
||||
export const LangProvider = ({ children }) => {
|
||||
const [lang, setLang] = useState("en"); // Estado para el idioma
|
||||
|
||||
// Ahora 'event' es el objeto de evento de React
|
||||
const toggleLang = (event) => {
|
||||
// Extraemos el valor de la opción seleccionada (ej: "es" o "en")
|
||||
const newLang = event.target.value;
|
||||
console.log("Nuevo idioma seleccionado:", newLang);
|
||||
// Establecemos el estado 'lang' con el valor seleccionado
|
||||
setLang(newLang);
|
||||
}
|
||||
|
||||
return (
|
||||
<langContext.Provider value={{ lang, toggleLang }}>
|
||||
{children}
|
||||
</langContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useLang = () => useContext(langContext);
|
||||
97
frontend/Frontend-Hotel/src/index.css
Normal file
97
frontend/Frontend-Hotel/src/index.css
Normal file
@@ -0,0 +1,97 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #cc0404de;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #ff8513;
|
||||
}
|
||||
|
||||
/*
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
height: auto;
|
||||
overflow-x: hidden;
|
||||
background-color: #f5f5f5;
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #791002;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #640f04;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
21
frontend/Frontend-Hotel/src/main.jsx
Normal file
21
frontend/Frontend-Hotel/src/main.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.jsx";
|
||||
//import { AuthProvider } from "./context/AuthContext.jsx";
|
||||
import "./styles/global.css";
|
||||
import './index.css'; // o './styles/tailwind.css'
|
||||
import { AuthProvider } from "./context/AuthContext.jsx";
|
||||
import { LangProvider } from "./context/LenguageContext.jsx";
|
||||
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<LangProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</LangProvider>
|
||||
|
||||
);
|
||||
40
frontend/Frontend-Hotel/src/pages/BasePositiva.jsx
Normal file
40
frontend/Frontend-Hotel/src/pages/BasePositiva.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export default function BasePositiva() {
|
||||
const [file, setFile] = useState(null);
|
||||
const [tipoOp, setTipoOp] = useState("14"); // 14=Alta, 15=Actualización
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!file) {
|
||||
alert("Seleccione un archivo ZIP válido");
|
||||
return;
|
||||
}
|
||||
// Simulación: aquí luego conectamos al WSUploadFiles
|
||||
alert(`Archivo ${file.name} enviado como operación ${tipoOp}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>Carga de Archivos - Base Positiva</h2>
|
||||
<form onSubmit={handleSubmit} className="form">
|
||||
<label>
|
||||
Tipo de Operación
|
||||
<select value={tipoOp} onChange={(e) => setTipoOp(e.target.value)}>
|
||||
<option value="14">Alta Base Positiva</option>
|
||||
<option value="15">Actualización Base Positiva</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Archivo ZIP
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={(e) => setFile(e.target.files[0])}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn btn--primary">Subir</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
159
frontend/Frontend-Hotel/src/pages/Contracts.jsx
Normal file
159
frontend/Frontend-Hotel/src/pages/Contracts.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Table from '../components/Table/HotelTable';
|
||||
import '../components/Filters/Filters.css';
|
||||
|
||||
// Fake data para probar la tabla con filtros
|
||||
const fakeContracts = [
|
||||
{
|
||||
id: '0000001',
|
||||
name: 'María Fernanda López',
|
||||
position: 'Concierge',
|
||||
area: 'Hotel',
|
||||
startDate: '2024-01-17',
|
||||
endDate: '2025-01-17',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
{
|
||||
id: '0000002',
|
||||
name: 'José Ángel Ramírez',
|
||||
position: 'Chef',
|
||||
area: 'Restaurant',
|
||||
startDate: '2024-01-15',
|
||||
endDate: '2025-01-15',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
{
|
||||
id: '0000003',
|
||||
name: 'Ana Sofía Torres',
|
||||
position: 'Chef',
|
||||
area: 'Restaurant',
|
||||
startDate: '2024-01-23',
|
||||
endDate: '2025-01-23',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
{
|
||||
id: '0000004',
|
||||
name: 'Luis Eduardo Mendoza',
|
||||
position: 'Hostess',
|
||||
area: 'Restaurant',
|
||||
startDate: '2024-01-28',
|
||||
endDate: '2025-04-28',
|
||||
status: 'INACTIVE',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Contracts() {
|
||||
const [contracts, setContracts] = useState([]);
|
||||
const [filteredContracts, setFilteredContracts] = useState([]);
|
||||
const [areaFilter, setAreaFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
|
||||
useEffect(() => {
|
||||
// Simular fetch de datos
|
||||
setContracts(fakeContracts);
|
||||
setFilteredContracts(fakeContracts);
|
||||
}, []);
|
||||
|
||||
// 🔍 Función para filtrar
|
||||
useEffect(() => {
|
||||
let filtered = [...contracts];
|
||||
|
||||
if (areaFilter) {
|
||||
filtered = filtered.filter((c) => c.area === areaFilter);
|
||||
}
|
||||
|
||||
if (statusFilter) {
|
||||
filtered = filtered.filter((c) => c.status === statusFilter);
|
||||
}
|
||||
|
||||
if (dateRange.from && dateRange.to) {
|
||||
filtered = filtered.filter(
|
||||
(c) => c.endDate >= dateRange.from && c.endDate <= dateRange.to
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredContracts(filtered);
|
||||
}, [areaFilter, statusFilter, dateRange, contracts]);
|
||||
|
||||
const columns = [
|
||||
{ header: 'CONTRACT ID', key: 'id' },
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'POSITION', key: 'position' },
|
||||
{ header: 'AREA', key: 'area' },
|
||||
{ header: 'START OF CONTRACT', key: 'startDate' },
|
||||
{ header: 'END OF CONTRACT', key: 'endDate' },
|
||||
{ header: 'STATUS', key: 'status' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Contracts</h2>
|
||||
|
||||
{/* Filtros */}
|
||||
<div style={{ display: 'flex', gap: '10px', marginBottom: '15px' }}>
|
||||
<select value={areaFilter} onChange={(e) => setAreaFilter(e.target.value)}>
|
||||
<option value="">Area: Hotel, Restaurant</option>
|
||||
<option value="Hotel">Hotel</option>
|
||||
<option value="Restaurant">Restaurant</option>
|
||||
</select>
|
||||
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="">Status: Active, Inactive</option>
|
||||
<option value="ACTIVE">ACTIVE</option>
|
||||
<option value="INACTIVE">INACTIVE</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.from}
|
||||
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.to}
|
||||
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table columns={columns} data={filteredContracts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// import React, { useEffect, useState } from 'react';
|
||||
// import { getContracts } from '../services/contractService';
|
||||
// import Table from '../components/Table/HotelTable';
|
||||
|
||||
// export default function Contracts() {
|
||||
// const [contracts, setContracts] = useState([]);
|
||||
|
||||
// useEffect(() => {
|
||||
// async function fetchData() {
|
||||
// const data = await getContracts();
|
||||
// setContracts(data);
|
||||
// }
|
||||
|
||||
// fetchData();
|
||||
// }, []);
|
||||
|
||||
// const columns = [
|
||||
// { header: 'CONTRACT ID', key: 'id' },
|
||||
// { header: 'NAME', key: 'name' },
|
||||
// { header: 'POSITION', key: 'position' },
|
||||
// { header: 'AREA', key: 'area' },
|
||||
// { header: 'START OF CONTRACT', key: 'startDate' },
|
||||
// { header: 'END OF CONTRACT', key: 'endDate' },
|
||||
// { header: 'STATUS', key: 'status' },
|
||||
// ];
|
||||
|
||||
// return (
|
||||
// <div>
|
||||
// <h2>Contracts</h2>
|
||||
// <Table columns={columns} data={contracts} />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
43
frontend/Frontend-Hotel/src/pages/Dashboard.jsx
Normal file
43
frontend/Frontend-Hotel/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../services/api.js";
|
||||
|
||||
export default function Dashboard() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
// Demo: trae usuarios desde la API configurada
|
||||
const { data } = await api.get("/users");
|
||||
if (mounted) setUsers(data);
|
||||
} catch (e) {
|
||||
setErr("No se pudo cargar el Dashboard");
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
if (loading) return <p>Cargando...</p>;
|
||||
if (err) return <p className="alert alert--error">{err}</p>;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>Dashboard</h2>
|
||||
<p>Usuarios demo desde la API:</p>
|
||||
<div className="grid">
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="card">
|
||||
<h3>{u.name}</h3>
|
||||
<p>{u.email}</p>
|
||||
<small>{u.username}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
11
frontend/Frontend-Hotel/src/pages/Dashboard/Budget.jsx
Normal file
11
frontend/Frontend-Hotel/src/pages/Dashboard/Budget.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Budget() {
|
||||
return (
|
||||
<div>
|
||||
<h2>Budget</h2>
|
||||
<p>Budget dashboard coming soon...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
11
frontend/Frontend-Hotel/src/pages/Dashboard/CostPerRoom.jsx
Normal file
11
frontend/Frontend-Hotel/src/pages/Dashboard/CostPerRoom.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function CostPerRoom() {
|
||||
return (
|
||||
<div>
|
||||
<h2>Cost Per Room</h2>
|
||||
<p>Cost per room analysis coming soon...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
11
frontend/Frontend-Hotel/src/pages/Dashboard/Expenses.jsx
Normal file
11
frontend/Frontend-Hotel/src/pages/Dashboard/Expenses.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Expenses() {
|
||||
return (
|
||||
<div>
|
||||
<h2>Expenses</h2>
|
||||
<p>Expenses dashboard coming soon...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
350
frontend/Frontend-Hotel/src/pages/Dashboard/HotelPL.css
Normal file
350
frontend/Frontend-Hotel/src/pages/Dashboard/HotelPL.css
Normal file
@@ -0,0 +1,350 @@
|
||||
.report-page {
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: #1a1a1a;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #5D1A2A;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-left: 15px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.pl-form-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.pl-section {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #5D1A2A;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-header-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin: 0 0 20px 0;
|
||||
border-bottom: 2px solid #5D1A2A;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-header-row .section-title {
|
||||
margin: 0;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.section-header-percentage {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #5D1A2A;
|
||||
text-align: left;
|
||||
padding-left: 212px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-row-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-col {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #374151;
|
||||
min-width: 200px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-label.label-bold {
|
||||
font-weight: 900;
|
||||
color: #000;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
background-color: #f9fafb;
|
||||
color: #111827;
|
||||
font-weight: 500;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #5D1A2A;
|
||||
}
|
||||
|
||||
.form-input.highlight {
|
||||
background-color: #fef3c7;
|
||||
border-color: #fbbf24;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.no-data-message {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.report-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-row-split {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-col {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.section-header-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-header-percentage {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pl-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Metrics Grid Styles */
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.metric-card h3 {
|
||||
color: #1a1a1a;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.count-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.currency-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.total-value {
|
||||
color: #5D1A2A;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
width: 120px;
|
||||
height: 40px;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.count-value {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.currency-value {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.total-value {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.metric-card h3 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.count-value {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.currency-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.total-value {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
438
frontend/Frontend-Hotel/src/pages/Dashboard/HotelPL.jsx
Normal file
438
frontend/Frontend-Hotel/src/pages/Dashboard/HotelPL.jsx
Normal file
@@ -0,0 +1,438 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import DateRangeFilter from '../../components/Filters/DateRangeFilter';
|
||||
import './HotelPL.css';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
|
||||
export default function HotelPL() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
const [revenue, setRevenue] = useState(0);
|
||||
const [cogs, setCogs] = useState(0);
|
||||
const [employeeShare, setEmployeeShare] = useState(0);
|
||||
const [tips, setTips] = useState(0);
|
||||
const [grossProfit, setGrossProfit] = useState(0);
|
||||
const [weightedCategoriesCost, setWeightedCategoriesCost] = useState([]);
|
||||
const [totalExpenses, setTotalExpenses] = useState(0);
|
||||
const [ebitda, setEbitda] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
return dateStr;
|
||||
};
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
const num = parseFloat(value || 0);
|
||||
return num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const calculatePercentage = (value, total) => {
|
||||
if (!total || total === 0) return '0.00';
|
||||
return ((parseFloat(value) / parseFloat(total)) * 100).toFixed(2);
|
||||
};
|
||||
|
||||
const formatPercentageForDisplay = (percentageValue) => {
|
||||
const numValue = parseFloat(percentageValue);
|
||||
return (numValue * 100).toFixed(2);
|
||||
};
|
||||
|
||||
const loadRevenue = async () => {
|
||||
const start_date = formatDate(dateRange.from);
|
||||
const end_date = formatDate(dateRange.to);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/hotelpl/totalrevenue`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_date, end_date })
|
||||
});
|
||||
const json = await res.json();
|
||||
setRevenue(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading revenue:', err);
|
||||
setRevenue(0);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCogs = async () => {
|
||||
const start_date = formatDate(dateRange.from);
|
||||
const end_date = formatDate(dateRange.to);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/hotelpl/cogs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_date, end_date })
|
||||
});
|
||||
const json = await res.json();
|
||||
setCogs(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading COGS:', err);
|
||||
setCogs(0);
|
||||
}
|
||||
};
|
||||
|
||||
const loadEmployeeShare = async () => {
|
||||
const start_date = formatDate(dateRange.from);
|
||||
const end_date = formatDate(dateRange.to);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/hotelpl/employeeshare`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_date, end_date })
|
||||
});
|
||||
const json = await res.json();
|
||||
setEmployeeShare(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading employee share:', err);
|
||||
setEmployeeShare(0);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTips = async () => {
|
||||
const start_date = formatDate(dateRange.from);
|
||||
const end_date = formatDate(dateRange.to);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/hotelpl/tips`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_date, end_date })
|
||||
});
|
||||
const json = await res.json();
|
||||
setTips(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading tips:', err);
|
||||
setTips(0);
|
||||
}
|
||||
};
|
||||
|
||||
const loadGrossProfit = async () => {
|
||||
const start_date = formatDate(dateRange.from);
|
||||
const end_date = formatDate(dateRange.to);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/hotelpl/grossprofit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_date, end_date })
|
||||
});
|
||||
const json = await res.json();
|
||||
setGrossProfit(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading gross profit:', err);
|
||||
setGrossProfit(0);
|
||||
}
|
||||
};
|
||||
|
||||
const loadWeightedCategoriesCost = async () => {
|
||||
const start_date = formatDate(dateRange.from);
|
||||
const end_date = formatDate(dateRange.to);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/hotelpl/weightedcategoriescost`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_date, end_date })
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
const mapped = json.data.map(item => ({
|
||||
id: item.id_expense_cat,
|
||||
category: item.category_name,
|
||||
es_category: item.spanish_name,
|
||||
total: item.total,
|
||||
participation: parseFloat(item.participation).toFixed(2) + '%'
|
||||
}));
|
||||
|
||||
setWeightedCategoriesCost(mapped);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading weigted categories:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadEbidta = async () => {
|
||||
const start_date = formatDate(dateRange.from);
|
||||
const end_date = formatDate(dateRange.to);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/hotelpl/ebitda`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_date, end_date })
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
const data = json.data[0];
|
||||
setTotalExpenses(parseFloat(data.expenses_total || 0));
|
||||
setEbitda(parseFloat(data.ebitda || 0));
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading ebitda:', err);
|
||||
setTotalExpenses(0);
|
||||
setEbitda(0);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const pad = (n) => n.toString().padStart(2, '0');
|
||||
const from = `${year}-${pad(month + 1)}-${pad(firstDay.getDate())}`;
|
||||
const to = `${year}-${pad(month + 1)}-${pad(lastDay.getDate())}`;
|
||||
setDateRange({ from, to });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dateRange.from || !dateRange.to) return;
|
||||
|
||||
setLoading(true);
|
||||
setRevenue(0);
|
||||
setCogs(0);
|
||||
setEmployeeShare(0);
|
||||
setGrossProfit(0);
|
||||
setWeightedCategoriesCost([]);
|
||||
setTotalExpenses(0);
|
||||
setEbitda(0);
|
||||
|
||||
Promise.all([
|
||||
loadRevenue(),
|
||||
loadCogs(),
|
||||
loadEmployeeShare(),
|
||||
loadTips(),
|
||||
loadGrossProfit(),
|
||||
loadWeightedCategoriesCost(),
|
||||
loadEbidta()
|
||||
]).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dateRange]);
|
||||
|
||||
const totalNetRevenue = revenue;
|
||||
|
||||
return (
|
||||
<div className="report-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
{lang === "en" ? "Hotel P&L" : "P&L del Hotel"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<DateRangeFilter
|
||||
dateRange={dateRange}
|
||||
onDateChange={setDateRange}
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner" />
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
<span className="loading-text">
|
||||
{lang === "en" ? "Loading financial data..." : "Cargando datos financieros..."}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pl-form-container">
|
||||
|
||||
<div className="pl-section">
|
||||
<div className="section-header-row">
|
||||
<h3 className="section-title">
|
||||
{lang === "en" ? "Income" : "Ingresos"}
|
||||
</h3>
|
||||
<span className="section-header-percentage">
|
||||
% {lang === "en" ? "Total Net Revenue" : "Ingreso Neto Total"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row-split">
|
||||
<div className="form-col">
|
||||
<label className="form-label">Lodging</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`$${formatCurrency(revenue)}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row-split">
|
||||
<div className="form-col">
|
||||
<label className="form-label label-bold">
|
||||
{lang === "en" ? "Total Net Revenue" : "Ingreso Neto Total"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`$${formatCurrency(totalNetRevenue)}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row-split">
|
||||
<div className="form-col">
|
||||
<label className="form-label">COGS</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`$${formatCurrency(cogs)}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="form-col">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`${formatPercentageForDisplay(calculatePercentage(cogs, totalNetRevenue))}%`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row-split">
|
||||
<div className="form-col">
|
||||
<label className="form-label">
|
||||
{lang === "en" ? "Employee Share" : "Participación Laboral"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`$${formatCurrency(employeeShare)}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="form-col">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`${formatPercentageForDisplay(calculatePercentage(employeeShare, totalNetRevenue))}%`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row-split">
|
||||
<div className="form-col">
|
||||
<label className="form-label">
|
||||
{lang === "en" ? "Tips" : "Propinas"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`$${formatCurrency(tips)}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row-split">
|
||||
<div className="form-col">
|
||||
<label className="form-label label-bold">
|
||||
{lang === "en" ? "Gross Profit" : "Utilidad Bruta"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`$${formatCurrency(grossProfit)}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="form-col">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`${formatPercentageForDisplay(calculatePercentage(grossProfit, totalNetRevenue))}%`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pl-section">
|
||||
<div className="section-header-row">
|
||||
<h3 className="section-title">
|
||||
{lang === "en" ? "Expenses" : "Gastos"}
|
||||
</h3>
|
||||
<span className="section-header-percentage">
|
||||
% {lang === "en" ? "Total Net Revenue" : "Ingreso Neto Total"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{weightedCategoriesCost.length > 0 ? (
|
||||
<>
|
||||
{weightedCategoriesCost.map((expense, index) => (
|
||||
<div key={index} className="form-row-split">
|
||||
<div className="form-col">
|
||||
<label className="form-label">
|
||||
{lang === "en" ? expense.category : expense.es_category}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`$${formatCurrency(expense.total)}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="form-col">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`${formatPercentageForDisplay(expense.participation.replace('%', ''))}%`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="no-data-message">
|
||||
{lang === "en" ? "No expense data available" : "No hay datos de gastos disponibles"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pl-section">
|
||||
<div className="form-row">
|
||||
<label className="form-label">
|
||||
{lang === "en" ? "Total Expenses" : "Gastos Totales"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`$${formatCurrency(totalExpenses)}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">
|
||||
{lang === "en" ? "EBITDA" : "Utilidad Operativa"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input highlight"
|
||||
value={`$${formatCurrency(ebitda)}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
frontend/Frontend-Hotel/src/pages/Dashboard/Income.css
Normal file
144
frontend/Frontend-Hotel/src/pages/Dashboard/Income.css
Normal file
@@ -0,0 +1,144 @@
|
||||
.income-page {
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: #1a1a1a;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
margin-bottom: 32px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.summary-cards-wrapper {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.table-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-spinner-large {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 5px solid rgba(0, 0, 0, 0.1);
|
||||
border-top-color: #fcd200;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.table-loading p {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.summary-cards-wrapper {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.summary-cards-wrapper {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.income-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.summary-cards-wrapper {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.table-loading {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.loading-spinner-large {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
365
frontend/Frontend-Hotel/src/pages/Dashboard/Income.jsx
Normal file
365
frontend/Frontend-Hotel/src/pages/Dashboard/Income.jsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Table from '../../components/Table/HotelTable';
|
||||
import DateRangeFilter from '../../components/Filters/DateRangeFilter';
|
||||
import SummaryCard from '../../components/SummaryCard';
|
||||
import ExcelExportButton from '../../components/ExcelExportButton';
|
||||
import '../../components/Filters/Filters.css';
|
||||
import './Income.css';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
|
||||
|
||||
// Ya no se usan datos estáticos para canales
|
||||
|
||||
|
||||
export default function Income() {
|
||||
const {lang} = useContext(langContext);
|
||||
// Card: Total income (dinámico)
|
||||
const [totalIncome, setTotalIncome] = useState(0);
|
||||
// Cards: canales (Booking, Expedia, Mr and Mrs Smith)
|
||||
const [channels, setChannels] = useState({ booking: 0, expedia: 0, mrsmith: 0 });
|
||||
// Tabla usa datos del endpoint
|
||||
const [filteredRooms, setFilteredRooms] = useState([]);
|
||||
const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
const [loadingChannels, setLoadingChannels] = useState(false);
|
||||
const [loadingTotalIncome, setLoadingTotalIncome] = useState(false);
|
||||
const [loadingTable, setLoadingTable] = useState(false);
|
||||
// Consumir el endpoint para las tarjetas de canales
|
||||
useEffect(() => {
|
||||
async function fetchChannels() {
|
||||
if (dateRange.from && dateRange.to) {
|
||||
setLoadingChannels(true);
|
||||
// Formatear fechas a dd-mm-yyyy
|
||||
const formatDate = (dateStr) => {
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
const checkin_initial = formatDate(dateRange.from);
|
||||
const checkin_final = formatDate(dateRange.to);
|
||||
try {
|
||||
const response = await fetch(import.meta.env.VITE_API_BASE_URL + '/incomes/channelscards', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkin_initial, checkin_final })
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log(data.data);
|
||||
setChannels({
|
||||
booking: data.data[3].total_channel || 0,
|
||||
expedia: data.data[1].total_channel || 0,
|
||||
mrsmith: data.data[4].total_channel || 0,
|
||||
});
|
||||
} catch {
|
||||
setChannels({ booking: 0, expedia: 0, mrsmith: 0 });
|
||||
} finally {
|
||||
setLoadingChannels(false);
|
||||
}
|
||||
} else {
|
||||
setChannels({ booking: 0, expedia: 0, mrsmith: 0 });
|
||||
setLoadingChannels(false);
|
||||
}
|
||||
}
|
||||
fetchChannels();
|
||||
}, [dateRange]);
|
||||
// Consumir el endpoint para la tarjeta Total income
|
||||
useEffect(() => {
|
||||
async function fetchTotalIncome() {
|
||||
if (dateRange.from && dateRange.to) {
|
||||
setLoadingTotalIncome(true);
|
||||
// Formatear fechas a dd-mm-yyyy
|
||||
const formatDate = (dateStr) => {
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
const checkin_initial = formatDate(dateRange.from);
|
||||
const checkin_final = formatDate(dateRange.to);
|
||||
try {
|
||||
const response = await fetch(import.meta.env.VITE_API_BASE_URL + '/incomes/totalincomes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkin_initial, checkin_final })
|
||||
});
|
||||
const data = await response.json();
|
||||
setTotalIncome(data.data || 0);
|
||||
} catch {
|
||||
setTotalIncome(0);
|
||||
} finally {
|
||||
setLoadingTotalIncome(false);
|
||||
}
|
||||
} else {
|
||||
setTotalIncome(0);
|
||||
setLoadingTotalIncome(false);
|
||||
}
|
||||
}
|
||||
fetchTotalIncome();
|
||||
}, [dateRange]);
|
||||
|
||||
// Al cargar la página, seleccionar el rango de fechas de un mes
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
// Primer día del mes
|
||||
const firstDay = new Date(year, month, 1);
|
||||
// Último día del mes
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
// Formato yyyy-mm-dd
|
||||
const pad = (n) => n.toString().padStart(2, '0');
|
||||
const from = `${year}-${pad(month + 1)}-${pad(firstDay.getDate())}`;
|
||||
const to = `${year}-${pad(month + 1)}-${pad(lastDay.getDate())}`;
|
||||
setDateRange({ from, to });
|
||||
}, []);
|
||||
|
||||
// Consumir el endpoint solo para la tabla
|
||||
useEffect(() => {
|
||||
async function fetchIncome() {
|
||||
if (dateRange.from && dateRange.to) {
|
||||
setLoadingTable(true);
|
||||
// Formatear fechas a dd-mm-yyyy
|
||||
const formatDate = (dateStr) => {
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
try {
|
||||
const checkin_initial = formatDate(dateRange.from);
|
||||
const checkin_final = formatDate(dateRange.to);
|
||||
// GET pero solo para la tabla
|
||||
const url = import.meta.env.VITE_API_BASE_URL + `/incomes/getincomes`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkin_initial, checkin_final })
|
||||
});
|
||||
const data = await response.json();
|
||||
setFilteredRooms( data.data || []);
|
||||
} catch {
|
||||
setFilteredRooms([]);
|
||||
} finally {
|
||||
setLoadingTable(false);
|
||||
}
|
||||
} else {
|
||||
setFilteredRooms([]);
|
||||
setLoadingTable(false);
|
||||
}
|
||||
}
|
||||
fetchIncome();
|
||||
}, [dateRange]);
|
||||
|
||||
const columns = [
|
||||
{ header: 'ROOM', key: 'room' },
|
||||
{ header: 'OCCUPATION', key: 'occupation' },
|
||||
{
|
||||
header: 'AVG. INCOME PER NIGHT',
|
||||
key: 'income_per_night',
|
||||
render: (value) => value ? `$${parseFloat(value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : ''
|
||||
},
|
||||
{
|
||||
header: 'TOTAL INCOME',
|
||||
key: 'total_income',
|
||||
render: (value) => value ? `$${parseFloat(value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : ''
|
||||
},
|
||||
];
|
||||
|
||||
const exportColumns = [
|
||||
{ header: lang === "es" ? "HABITACIÓN" : "ROOM", key: "room" },
|
||||
{ header: lang === "es" ? "OCUPACIÓN" : "OCCUPATION", key: "occupation" },
|
||||
{ header: lang === "es" ? "INGRESO PROMEDIO POR NOCHE" : "AVG. INCOME PER NIGHT", key: "income_per_night" },
|
||||
{ header: lang === "es" ? "INGRESO TOTAL" : "TOTAL INCOME", key: "total_income" },
|
||||
];
|
||||
|
||||
const dataTransform = (data) => {
|
||||
return data.map((row) => ({
|
||||
...row,
|
||||
income_per_night: row.income_per_night ? `$${parseFloat(row.income_per_night).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : '',
|
||||
total_income: row.total_income ? `$${parseFloat(row.total_income).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : '',
|
||||
}));
|
||||
};
|
||||
|
||||
// Cards usan datos dinámicos de canales y totalIncome
|
||||
const bookingPercentage = totalIncome
|
||||
? Math.round((channels.booking / totalIncome) * 100)
|
||||
: 0;
|
||||
const expediaPercentage = totalIncome
|
||||
? Math.round((channels.expedia / totalIncome) * 100)
|
||||
: 0;
|
||||
const mrsmithPercentage = totalIncome
|
||||
? Math.round((channels.mrsmith / totalIncome) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="income-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
{lang === "en" ? "Little Hotelier" : "Little Hotelier"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<DateRangeFilter
|
||||
dateRange={dateRange}
|
||||
onDateChange={setDateRange}
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
<div className="summary-section">
|
||||
<div className="summary-cards-wrapper">
|
||||
<SummaryCard
|
||||
title={lang === "en" ? "Total Income" : "Total de Ingresos"}
|
||||
amount={parseFloat(totalIncome || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
isPrimary={true}
|
||||
isLoading={loadingTotalIncome}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Booking"
|
||||
amount={parseFloat(channels.booking || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
percentage={bookingPercentage}
|
||||
isLoading={loadingChannels}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Expedia"
|
||||
amount={parseFloat(channels.expedia || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
percentage={expediaPercentage}
|
||||
isLoading={loadingChannels}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Mr and Mrs Smith"
|
||||
amount={parseFloat(channels.mrsmith || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
percentage={mrsmithPercentage}
|
||||
isLoading={loadingChannels}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '15px' }}>
|
||||
<ExcelExportButton
|
||||
data={filteredRooms}
|
||||
columns={exportColumns}
|
||||
filenamePrefix={lang === "es" ? "informe-ingresos" : "income-report"}
|
||||
sheetName={lang === "es" ? "Datos de Ingresos" : "Income Data"}
|
||||
dataTransform={dataTransform}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="table-section">
|
||||
{loadingTable ? (
|
||||
<div className="table-loading">
|
||||
<div className="loading-spinner-large"></div>
|
||||
<p>{lang === "en" ? "Loading data..." : "Cargando datos..."}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table columns={columns} data={filteredRooms} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// const fakeIncome = {
|
||||
// totalIncome: 15950,
|
||||
// booking: 7975,
|
||||
// expedia: 4785,
|
||||
// rooms: [
|
||||
// {
|
||||
// name: 'San Miguel Presidential',
|
||||
// occupation: '10 nights',
|
||||
// avgIncomePerNight: '$300',
|
||||
// totalIncome: '$3,000',
|
||||
// },
|
||||
// {
|
||||
// name: 'Angels View',
|
||||
// occupation: '15 nights',
|
||||
// avgIncomePerNight: '$300',
|
||||
// totalIncome: '$4,500',
|
||||
// },
|
||||
// {
|
||||
// name: 'Angels Dome',
|
||||
// occupation: '17 nights',
|
||||
// avgIncomePerNight: '$250',
|
||||
// totalIncome: '$4,250',
|
||||
// },
|
||||
// {
|
||||
// name: 'Celestial',
|
||||
// occupation: '21 nights',
|
||||
// avgIncomePerNight: '$200',
|
||||
// totalIncome: '$4,200',
|
||||
// },
|
||||
// ],
|
||||
// };
|
||||
|
||||
// export default function Income() {
|
||||
// const [income, setIncome] = useState({});
|
||||
// const [filteredRooms, setFilteredRooms] = useState([]);
|
||||
// const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
|
||||
// useEffect(() => {
|
||||
// // Simular carga de datos
|
||||
// setIncome(fakeIncome);
|
||||
// setFilteredRooms(fakeIncome.rooms);
|
||||
// }, []);
|
||||
|
||||
// // Si en el futuro deseas aplicar filtros por fecha real:
|
||||
// useEffect(() => {
|
||||
// if (dateRange.from && dateRange.to) {
|
||||
// // Lógica de filtrado por fecha (si tuvieras fechas en los datos de cada habitación)
|
||||
// // Aquí se deja como estático porque los datos no tienen campo de fecha
|
||||
// setFilteredRooms(fakeIncome.rooms); // Simulando que no cambia
|
||||
// }
|
||||
// }, [dateRange]);
|
||||
|
||||
// const columns = [
|
||||
// { header: 'ROOM', key: 'name' },
|
||||
// { header: 'OCCUPATION', key: 'occupation' },
|
||||
// { header: 'AVG. INCOME PER NIGHT', key: 'avgIncomePerNight' },
|
||||
// { header: 'TOTAL INCOME', key: 'totalIncome' },
|
||||
// ];
|
||||
|
||||
// const bookingPercentage = income.totalIncome
|
||||
// ? Math.round((income.booking / income.totalIncome) * 100)
|
||||
// : 0;
|
||||
// const expediaPercentage = income.totalIncome
|
||||
// ? Math.round((income.expedia / income.totalIncome) * 100)
|
||||
// : 0;
|
||||
|
||||
// return (
|
||||
// <div>
|
||||
// <h2>INCOME</h2>
|
||||
|
||||
// {/* Date Filters */}
|
||||
// <div className="page-filters">
|
||||
// <input
|
||||
// type="date"
|
||||
// value={dateRange.from}
|
||||
// onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
|
||||
// />
|
||||
// <input
|
||||
// type="date"
|
||||
// value={dateRange.to}
|
||||
// onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
// />
|
||||
// </div>
|
||||
|
||||
// {/* Summary Cards */}
|
||||
// <div style={{ display: 'flex', gap: '240px', marginBottom: '20px' }}>
|
||||
// <div className="summary-card">
|
||||
// <strong>Total income</strong>
|
||||
// <div>${income.totalIncome?.toLocaleString() || 0}</div>
|
||||
// </div>
|
||||
// <div className="summary-card">
|
||||
// <strong>Booking</strong>
|
||||
// <div>${income.booking?.toLocaleString() || 0}</div>
|
||||
// <div>{bookingPercentage}%</div>
|
||||
// </div>
|
||||
// <div className="summary-card">
|
||||
// <strong>Expedia</strong>
|
||||
// <div>${income.expedia?.toLocaleString() || 0}</div>
|
||||
// <div>{expediaPercentage}%</div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {/* Income Table */}
|
||||
// <Table columns={columns} data={filteredRooms} />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
|
||||
//style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginBottom: '15px' }}
|
||||
@@ -0,0 +1,348 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import DateRangeFilter from '../../components/Filters/DateRangeFilter';
|
||||
import './HotelPL.css';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
|
||||
export default function RestaurantAnalysis() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
const [countticket, setCountticket] = useState(0);
|
||||
const [efectivo, setEfectivo] = useState(0);
|
||||
const [otros, setOtros] = useState(0);
|
||||
const [propinas, setPropinas] = useState(0);
|
||||
const [tarjeta, setTarjeta] = useState(0);
|
||||
const [vales, setVales] = useState(0);
|
||||
const [sumatotal, setSumatotal] = useState(0);
|
||||
const [ticketpromedio, setTicketpromedio] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Set default dates to current month
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
const formatDateForInput = (date) => {
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
setDateRange({
|
||||
from: formatDateForInput(startOfMonth),
|
||||
to: formatDateForInput(endOfMonth)
|
||||
});
|
||||
}, []);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
const num = parseFloat(value || 0);
|
||||
return num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const loadCountticket = useCallback(async () => {
|
||||
if (!dateRange.from || !dateRange.to) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/incomes/countticket`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
initial_date: formatDate(dateRange.from),
|
||||
final_date: formatDate(dateRange.to)
|
||||
})
|
||||
});
|
||||
const json = await res.json();
|
||||
setCountticket(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading countticket:', err);
|
||||
setCountticket(0);
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
const loadEfectivo = useCallback(async () => {
|
||||
if (!dateRange.from || !dateRange.to) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/incomes/efectivo`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
initial_date: formatDate(dateRange.from),
|
||||
final_date: formatDate(dateRange.to)
|
||||
})
|
||||
});
|
||||
const json = await res.json();
|
||||
setEfectivo(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading efectivo:', err);
|
||||
setEfectivo(0);
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
const loadOtros = useCallback(async () => {
|
||||
if (!dateRange.from || !dateRange.to) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/incomes/otros`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
initial_date: formatDate(dateRange.from),
|
||||
final_date: formatDate(dateRange.to)
|
||||
})
|
||||
});
|
||||
const json = await res.json();
|
||||
setOtros(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading otros:', err);
|
||||
setOtros(0);
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
const loadPropinas = useCallback(async () => {
|
||||
if (!dateRange.from || !dateRange.to) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/incomes/propinas`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
initial_date: formatDate(dateRange.from),
|
||||
final_date: formatDate(dateRange.to)
|
||||
})
|
||||
});
|
||||
const json = await res.json();
|
||||
setPropinas(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading propinas:', err);
|
||||
setPropinas(0);
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
const loadTarjeta = useCallback(async () => {
|
||||
if (!dateRange.from || !dateRange.to) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/incomes/tarjeta`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
initial_date: formatDate(dateRange.from),
|
||||
final_date: formatDate(dateRange.to)
|
||||
})
|
||||
});
|
||||
const json = await res.json();
|
||||
setTarjeta(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading tarjeta:', err);
|
||||
setTarjeta(0);
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
const loadVales = useCallback(async () => {
|
||||
if (!dateRange.from || !dateRange.to) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/incomes/vales`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
initial_date: formatDate(dateRange.from),
|
||||
final_date: formatDate(dateRange.to)
|
||||
})
|
||||
});
|
||||
const json = await res.json();
|
||||
setVales(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading vales:', err);
|
||||
setVales(0);
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
const loadSumatotal = useCallback(async () => {
|
||||
if (!dateRange.from || !dateRange.to) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/incomes/sumatotal`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
initial_date: formatDate(dateRange.from),
|
||||
final_date: formatDate(dateRange.to)
|
||||
})
|
||||
});
|
||||
const json = await res.json();
|
||||
setSumatotal(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading sumatotal:', err);
|
||||
setSumatotal(0);
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
const loadTicketpromedio = useCallback(async () => {
|
||||
if (!dateRange.from || !dateRange.to) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/incomes/ticketpromedio`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
initial_date: formatDate(dateRange.from),
|
||||
final_date: formatDate(dateRange.to)
|
||||
})
|
||||
});
|
||||
const json = await res.json();
|
||||
setTicketpromedio(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading ticketpromedio:', err);
|
||||
setTicketpromedio(0);
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAllData = async () => {
|
||||
if (!dateRange.from || !dateRange.to) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
loadCountticket(),
|
||||
loadEfectivo(),
|
||||
loadOtros(),
|
||||
loadPropinas(),
|
||||
loadTarjeta(),
|
||||
loadVales(),
|
||||
loadSumatotal(),
|
||||
loadTicketpromedio()
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAllData();
|
||||
}, [dateRange, loadCountticket, loadEfectivo, loadOtros, loadPropinas, loadTarjeta, loadVales, loadSumatotal, loadTicketpromedio]);
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
{lang === "en" ? "Restaurant Analysis" : "Análisis de Restaurante"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="filters-section">
|
||||
<DateRangeFilter
|
||||
dateRange={dateRange}
|
||||
onDateChange={setDateRange}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="metrics-grid">
|
||||
{/* Count Tickets Card */}
|
||||
<div className="metric-card">
|
||||
<h3>{lang === "en" ? "Count Tickets" : "Cantidad de Tickets"}</h3>
|
||||
<div className="metric-value">
|
||||
{loading ? (
|
||||
<div className="loading-skeleton"></div>
|
||||
) : (
|
||||
<span className="count-value">{countticket.toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Efectivo Card */}
|
||||
<div className="metric-card">
|
||||
<h3>{lang === "en" ? "Cash" : "Efectivo"}</h3>
|
||||
<div className="metric-value">
|
||||
{loading ? (
|
||||
<div className="loading-skeleton"></div>
|
||||
) : (
|
||||
<span className="currency-value">${formatCurrency(efectivo)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Otros Card */}
|
||||
<div className="metric-card">
|
||||
<h3>{lang === "en" ? "Others" : "Otros"}</h3>
|
||||
<div className="metric-value">
|
||||
{loading ? (
|
||||
<div className="loading-skeleton"></div>
|
||||
) : (
|
||||
<span className="currency-value">${formatCurrency(otros)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Propinas Card */}
|
||||
<div className="metric-card">
|
||||
<h3>{lang === "en" ? "Tips" : "Propinas"}</h3>
|
||||
<div className="metric-value">
|
||||
{loading ? (
|
||||
<div className="loading-skeleton"></div>
|
||||
) : (
|
||||
<span className="currency-value">${formatCurrency(propinas)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tarjeta Card */}
|
||||
<div className="metric-card">
|
||||
<h3>{lang === "en" ? "Card" : "Tarjeta"}</h3>
|
||||
<div className="metric-value">
|
||||
{loading ? (
|
||||
<div className="loading-skeleton"></div>
|
||||
) : (
|
||||
<span className="currency-value">${formatCurrency(tarjeta)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vales Card */}
|
||||
<div className="metric-card">
|
||||
<h3>{lang === "en" ? "Vouchers" : "Vales"}</h3>
|
||||
<div className="metric-value">
|
||||
{loading ? (
|
||||
<div className="loading-skeleton"></div>
|
||||
) : (
|
||||
<span className="currency-value">${formatCurrency(vales)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suma Total Card */}
|
||||
<div className="metric-card">
|
||||
<h3>{lang === "en" ? "Total Sum" : "Suma Total"}</h3>
|
||||
<div className="metric-value">
|
||||
{loading ? (
|
||||
<div className="loading-skeleton"></div>
|
||||
) : (
|
||||
<span className="currency-value total-value">${formatCurrency(sumatotal)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ticket Promedio Card */}
|
||||
<div className="metric-card">
|
||||
<h3>{lang === "en" ? "Average Ticket" : "Ticket Promedio"}</h3>
|
||||
<div className="metric-value">
|
||||
{loading ? (
|
||||
<div className="loading-skeleton"></div>
|
||||
) : (
|
||||
<span className="currency-value">${formatCurrency(ticketpromedio)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
349
frontend/Frontend-Hotel/src/pages/Dashboard/RestaurantPL.jsx
Normal file
349
frontend/Frontend-Hotel/src/pages/Dashboard/RestaurantPL.jsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import DateRangeFilter from '../../components/Filters/DateRangeFilter';
|
||||
import './HotelPL.css';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
|
||||
export default function RestaurantPL() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
const [revenue, setRevenue] = useState(0);
|
||||
const [cogs, setCogs] = useState(0);
|
||||
const [grossProfit, setGrossProfit] = useState(0);
|
||||
const [weightedCategoriesCost, setWeightedCategoriesCost] = useState([]);
|
||||
const [totalExpenses, setTotalExpenses] = useState(0);
|
||||
const [ebitda, setEbitda] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
return dateStr;
|
||||
};
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
const num = parseFloat(value || 0);
|
||||
return num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const calculatePercentage = (value, total) => {
|
||||
if (!total || total === 0) return '0.00';
|
||||
return ((parseFloat(value) / parseFloat(total)) * 100).toFixed(2);
|
||||
};
|
||||
|
||||
const formatPercentageForDisplay = (percentageValue) => {
|
||||
const numValue = parseFloat(percentageValue);
|
||||
return (numValue * 100).toFixed(2);
|
||||
};
|
||||
|
||||
const loadRevenue = async () => {
|
||||
const start_date = formatDate(dateRange.from);
|
||||
const end_date = formatDate(dateRange.to);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/restaurantpl/totalrevenue`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_date, end_date })
|
||||
});
|
||||
const json = await res.json();
|
||||
setRevenue(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading revenue:', err);
|
||||
setRevenue(0);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCogs = async () => {
|
||||
const start_date = formatDate(dateRange.from);
|
||||
const end_date = formatDate(dateRange.to);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/restaurantpl/cogs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_date, end_date })
|
||||
});
|
||||
const json = await res.json();
|
||||
setCogs(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading COGS:', err);
|
||||
setCogs(0);
|
||||
}
|
||||
};
|
||||
|
||||
const loadGrossProfit = async () => {
|
||||
const start_date = formatDate(dateRange.from);
|
||||
const end_date = formatDate(dateRange.to);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/restaurantpl/grossprofit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_date, end_date })
|
||||
});
|
||||
const json = await res.json();
|
||||
setGrossProfit(parseFloat(json.data || 0));
|
||||
} catch (err) {
|
||||
console.error('Error loading gross profit:', err);
|
||||
setGrossProfit(0);
|
||||
}
|
||||
};
|
||||
|
||||
const loadWeightedCategoriesCost = async () => {
|
||||
const start_date = formatDate(dateRange.from);
|
||||
const end_date = formatDate(dateRange.to);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/restaurantpl/weightedcategoriescost`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_date, end_date })
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
const mapped = json.data.map(item => ({
|
||||
id: item.id_expense_cat,
|
||||
category: item.category_name,
|
||||
es_category: item.spanish_name,
|
||||
total: item.total,
|
||||
participation: parseFloat(item.participation).toFixed(2) + '%'
|
||||
}));
|
||||
|
||||
setWeightedCategoriesCost(mapped);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading weigted categories:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadEbidta = async () => {
|
||||
const start_date = formatDate(dateRange.from);
|
||||
const end_date = formatDate(dateRange.to);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/restaurantpl/ebitda`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_date, end_date })
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
const data = json.data[0];
|
||||
setTotalExpenses(parseFloat(data.expenses_total || 0));
|
||||
setEbitda(parseFloat(data.ebitda || 0));
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading ebitda:', err);
|
||||
setTotalExpenses(0);
|
||||
setEbitda(0);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const pad = (n) => n.toString().padStart(2, '0');
|
||||
const from = `${year}-${pad(month + 1)}-${pad(firstDay.getDate())}`;
|
||||
const to = `${year}-${pad(month + 1)}-${pad(lastDay.getDate())}`;
|
||||
setDateRange({ from, to });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dateRange.from || !dateRange.to) return;
|
||||
|
||||
setLoading(true);
|
||||
setRevenue(0);
|
||||
setCogs(0);
|
||||
setGrossProfit(0);
|
||||
setWeightedCategoriesCost([]);
|
||||
setTotalExpenses(0);
|
||||
setEbitda(0);
|
||||
|
||||
Promise.all([
|
||||
loadRevenue(),
|
||||
loadCogs(),
|
||||
loadGrossProfit(),
|
||||
loadWeightedCategoriesCost(),
|
||||
loadEbidta()
|
||||
]).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dateRange]);
|
||||
|
||||
const totalNetRevenue = revenue;
|
||||
|
||||
return (
|
||||
<div className="report-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
{lang === "en" ? "Restaurant P&L" : "P&L del Restaurante"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<DateRangeFilter
|
||||
dateRange={dateRange}
|
||||
onDateChange={setDateRange}
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner" />
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
<span className="loading-text">
|
||||
{lang === "en" ? "Loading financial data..." : "Cargando datos financieros..."}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pl-form-container">
|
||||
|
||||
<div className="pl-section">
|
||||
<div className="section-header-row">
|
||||
<h3 className="section-title">
|
||||
{lang === "en" ? "Income" : "Ingresos"}
|
||||
</h3>
|
||||
<span className="section-header-percentage">
|
||||
% {lang === "en" ? "Total Net Revenue" : "Ingreso Neto Total"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row-split">
|
||||
<div className="form-col">
|
||||
<label className="form-label label-bold">
|
||||
{lang === "en" ? "Total Net Revenue" : "Ingreso Neto Total"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`$${formatCurrency(totalNetRevenue)}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row-split">
|
||||
<div className="form-col">
|
||||
<label className="form-label">COGS</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`$${formatCurrency(cogs)}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="form-col">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`${formatPercentageForDisplay(calculatePercentage(cogs, totalNetRevenue))}%`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row-split">
|
||||
<div className="form-col">
|
||||
<label className="form-label label-bold">
|
||||
{lang === "en" ? "Gross Profit" : "Utilidad Bruta"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`$${formatCurrency(grossProfit)}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="form-col">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`${formatPercentageForDisplay(calculatePercentage(grossProfit, totalNetRevenue))}%`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pl-section">
|
||||
<div className="section-header-row">
|
||||
<h3 className="section-title">
|
||||
{lang === "en" ? "Expenses" : "Gastos"}
|
||||
</h3>
|
||||
<span className="section-header-percentage">
|
||||
% {lang === "en" ? "Total Net Revenue" : "Ingreso Neto Total"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{weightedCategoriesCost.length > 0 ? (
|
||||
<>
|
||||
{weightedCategoriesCost.map((expense, index) => (
|
||||
<div key={index} className="form-row-split">
|
||||
<div className="form-col">
|
||||
<label className="form-label">
|
||||
{lang === "en" ? expense.category : expense.es_category}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`$${formatCurrency(expense.total)}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="form-col">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`${formatPercentageForDisplay(expense.participation.replace('%', ''))}%`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="no-data-message">
|
||||
{lang === "en" ? "No expense data available" : "No hay datos de gastos disponibles"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pl-section">
|
||||
<div className="form-row">
|
||||
<label className="form-label">
|
||||
{lang === "en" ? "Total Expenses" : "Gastos Totales"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={`$${formatCurrency(totalExpenses)}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">
|
||||
{lang === "en" ? "EBITDA" : "Utilidad Operativa"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input highlight"
|
||||
value={`$${formatCurrency(ebitda)}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
frontend/Frontend-Hotel/src/pages/Dashboard/RoomAnalysis.jsx
Normal file
234
frontend/Frontend-Hotel/src/pages/Dashboard/RoomAnalysis.jsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import React, { useEffect, useState, useContext } from 'react';
|
||||
import DateRangeFilter from '../../components/Filters/DateRangeFilter';
|
||||
import SummaryCard from '../../components/SummaryCard';
|
||||
import Table from '../../components/Table/HotelTable';
|
||||
import ExcelExportButton from '../../components/ExcelExportButton';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
import '../../components/Filters/Filters.css';
|
||||
import './Income.css';
|
||||
|
||||
export default function RoomAnalysis() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
const [reportData, setReportData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
const num = parseFloat(value || 0);
|
||||
return num.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
};
|
||||
|
||||
const fetchReportData = async () => {
|
||||
if (!dateRange.from || !dateRange.to) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/incomes/reportincomes`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
initial_date: dateRange.from,
|
||||
final_date: dateRange.to,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
setReportData(data);
|
||||
} else if (data.data && Array.isArray(data.data)) {
|
||||
setReportData(data.data);
|
||||
} else if (data.result && Array.isArray(data.result)) {
|
||||
setReportData(data.result);
|
||||
} else {
|
||||
setReportData([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching report data:', err);
|
||||
setError(err.message);
|
||||
setReportData([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
const pad = (n) => n.toString().padStart(2, '0');
|
||||
const from = `${year}-${pad(month + 1)}-${pad(firstDay.getDate())}`;
|
||||
const to = `${year}-${pad(month + 1)}-${pad(lastDay.getDate())}`;
|
||||
|
||||
setDateRange({ from, to });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dateRange.from && dateRange.to) {
|
||||
fetchReportData();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dateRange]);
|
||||
|
||||
const calculateSummary = () => {
|
||||
if (!reportData.length) return { revpar: 0, fixedCost: 0 };
|
||||
|
||||
const totalIncome = reportData.reduce((sum, item) => {
|
||||
return sum + parseFloat(item.total_income || 0);
|
||||
}, 0);
|
||||
|
||||
const totalUses = reportData.reduce((sum, item) => {
|
||||
return sum + parseInt(item.total_uses || 0, 10);
|
||||
}, 0);
|
||||
|
||||
const revpar = totalUses > 0 ? totalIncome / totalUses : 0;
|
||||
const fixedCost = totalIncome;
|
||||
|
||||
return { revpar, fixedCost };
|
||||
};
|
||||
|
||||
const summary = calculateSummary();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: 'ROOM',
|
||||
key: 'room_type',
|
||||
render: (value) => value || '-'
|
||||
},
|
||||
{
|
||||
header: 'ADR',
|
||||
key: 'ADR',
|
||||
render: (value) => `$${formatCurrency(value || 0)}`
|
||||
},
|
||||
{
|
||||
header: 'CPOR',
|
||||
key: 'CPOR',
|
||||
render: (value) => `$${formatCurrency(value || 0)}`
|
||||
},
|
||||
{
|
||||
header: 'CPAR',
|
||||
key: 'CPAR',
|
||||
render: (value) => `$${formatCurrency(value || 0)}`
|
||||
},
|
||||
{
|
||||
header: 'PPOR',
|
||||
key: 'PPOR',
|
||||
render: (value) => `$${formatCurrency(value || 0)}`
|
||||
},
|
||||
{
|
||||
header: 'BREAK EVEN POINT',
|
||||
key: 'break_event_point',
|
||||
render: (value) => parseFloat(value || 0).toFixed(2)
|
||||
},
|
||||
{
|
||||
header: 'GIFT ROOM',
|
||||
key: 'gift_room',
|
||||
render: (value) => `$${formatCurrency(value || 0)}`
|
||||
},
|
||||
];
|
||||
|
||||
const exportColumns = [
|
||||
{ header: lang === "es" ? "HABITACIÓN" : "ROOM", key: "room_type" },
|
||||
{ header: lang === "es" ? "ADR" : "ADR", key: "ADR" },
|
||||
{ header: lang === "es" ? "CPOR" : "CPOR", key: "CPOR" },
|
||||
{ header: lang === "es" ? "CPAR" : "CPAR", key: "CPAR" },
|
||||
{ header: lang === "es" ? "PPOR" : "PPOR", key: "PPOR" },
|
||||
{ header: lang === "es" ? "PUNTO DE EQUILIBRIO" : "BREAK EVEN POINT", key: "break_event_point" },
|
||||
{ header: lang === "es" ? "HABITACIÓN DE REGALO" : "GIFT ROOM", key: "gift_room" },
|
||||
];
|
||||
|
||||
const dataTransform = (data) => {
|
||||
return data.map((row) => ({
|
||||
...row,
|
||||
ADR: row.ADR ? `$${formatCurrency(row.ADR)}` : '',
|
||||
CPOR: row.CPOR ? `$${formatCurrency(row.CPOR)}` : '',
|
||||
CPAR: row.CPAR ? `$${formatCurrency(row.CPAR)}` : '',
|
||||
PPOR: row.PPOR ? `$${formatCurrency(row.PPOR)}` : '',
|
||||
break_event_point: row.break_event_point ? parseFloat(row.break_event_point || 0).toFixed(2) : '',
|
||||
gift_room: row.gift_room ? `$${formatCurrency(row.gift_room)}` : '',
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="income-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
{lang === 'en' ? 'Room Analysis' : 'Análisis de Habitaciones'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<DateRangeFilter
|
||||
dateRange={dateRange}
|
||||
onDateChange={setDateRange}
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
<div className="summary-section">
|
||||
<div className="summary-cards-wrapper">
|
||||
<SummaryCard
|
||||
title="RevPAR"
|
||||
amount={formatCurrency(summary.revpar)}
|
||||
isPrimary={true}
|
||||
isLoading={loading}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Fixed Cost"
|
||||
amount={formatCurrency(summary.fixedCost)}
|
||||
isPrimary={true}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '15px' }}>
|
||||
<ExcelExportButton
|
||||
data={reportData}
|
||||
columns={exportColumns}
|
||||
filenamePrefix={lang === "es" ? "analisis-habitaciones" : "room-analysis"}
|
||||
sheetName={lang === "es" ? "Análisis de Habitaciones" : "Room Analysis"}
|
||||
dataTransform={dataTransform}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="table-section">
|
||||
{loading ? (
|
||||
<div className="table-loading">
|
||||
<div className="loading-spinner-large"></div>
|
||||
<p>{lang === 'en' ? 'Loading room analysis data...' : 'Cargando datos de análisis de habitaciones...'}</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="error-container" style={{
|
||||
textAlign: 'center',
|
||||
padding: '2rem',
|
||||
color: '#d32f2f',
|
||||
backgroundColor: '#ffebee',
|
||||
borderRadius: '8px',
|
||||
margin: '1rem 0'
|
||||
}}>
|
||||
<p>{lang === 'en' ? `Error loading data: ${error}` : `Error al cargar datos: ${error}`}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table columns={columns} data={reportData} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
frontend/Frontend-Hotel/src/pages/Employees.jsx
Normal file
241
frontend/Frontend-Hotel/src/pages/Employees.jsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Table from '../components/Table/HotelTable';
|
||||
import '../components/Filters/Filters.css';
|
||||
|
||||
// Fake data para probar la tabla con filtros
|
||||
const fakeEmployees = [
|
||||
{
|
||||
id: '0001',
|
||||
name: 'María Fernanda López',
|
||||
nss: '00111111',
|
||||
position: 'Concierge',
|
||||
area: 'Hotel',
|
||||
phone: '33-33-33-33-33',
|
||||
endDate: '2025-01-17',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
{
|
||||
id: '0002',
|
||||
name: 'José Ángel Ramírez',
|
||||
nss: '00111111',
|
||||
position: 'Chef',
|
||||
area: 'Restaurant',
|
||||
phone: '33-33-33-33-33',
|
||||
endDate: '2025-01-15',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
{
|
||||
id: '0003',
|
||||
name: 'Ana Sofía Torres',
|
||||
nss: '00111111',
|
||||
position: 'Chef',
|
||||
area: 'Restaurant',
|
||||
phone: '33-33-33-33-33',
|
||||
endDate: '2025-01-23',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
{
|
||||
id: '0004',
|
||||
name: 'Luis Eduardo Mendoza',
|
||||
nss: '00111111',
|
||||
position: 'Hostess',
|
||||
area: 'Restaurant',
|
||||
phone: '33-33-33-33-33',
|
||||
endDate: '2025-04-28',
|
||||
status: 'INACTIVE',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Employees() {
|
||||
const [employees, setEmployees] = useState([]);
|
||||
const [filteredEmployees, setFilteredEmployees] = useState([]);
|
||||
|
||||
const [areaFilter, setAreaFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
|
||||
useEffect(() => {
|
||||
setEmployees(fakeEmployees);
|
||||
setFilteredEmployees(fakeEmployees);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let filtered = [...employees];
|
||||
|
||||
if (areaFilter) {
|
||||
filtered = filtered.filter((e) => e.area === areaFilter);
|
||||
}
|
||||
|
||||
if (statusFilter) {
|
||||
filtered = filtered.filter((e) => e.status === statusFilter);
|
||||
}
|
||||
|
||||
if (dateRange.from && dateRange.to) {
|
||||
filtered = filtered.filter(
|
||||
(e) => e.endDate >= dateRange.from && e.endDate <= dateRange.to
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredEmployees(filtered);
|
||||
}, [areaFilter, statusFilter, dateRange, employees]);
|
||||
|
||||
const columns = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'NSS', key: 'nss' },
|
||||
{ header: 'POSITION', key: 'position' },
|
||||
{ header: 'AREA', key: 'area' },
|
||||
{ header: 'PHONE', key: 'phone' },
|
||||
{ header: 'END OF CONTRACT', key: 'endDate' },
|
||||
{
|
||||
header: 'STATUS',
|
||||
key: 'status',
|
||||
render: (status) => (
|
||||
<button className={`status-button ${status.toLowerCase()}`}>
|
||||
{status}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Employees</h2>
|
||||
|
||||
{/* Filtros */}
|
||||
<div style={{ display: 'flex', gap: '10px', marginBottom: '15px' }}>
|
||||
<select value={areaFilter} onChange={(e) => setAreaFilter(e.target.value)}>
|
||||
<option value="">Area: Hotel, Restaurant</option>
|
||||
<option value="Hotel">Hotel</option>
|
||||
<option value="Restaurant">Restaurant</option>
|
||||
</select>
|
||||
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="">Status: Active, Inactive</option>
|
||||
<option value="ACTIVE">ACTIVE</option>
|
||||
<option value="INACTIVE">INACTIVE</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.from}
|
||||
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.to}
|
||||
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table columns={columns} data={filteredEmployees} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// import React, { useEffect, useState } from 'react';
|
||||
// import Table from '../components/Table/HotelTable';
|
||||
// import '../components/Filters/Filters.css';
|
||||
// import Filters from '../components/Filters/Filters';
|
||||
|
||||
// export default function Employees() {
|
||||
// const [employees, setEmployees] = useState([]);
|
||||
|
||||
// // Filtros
|
||||
// const [selectedArea, setSelectedArea] = useState('Hotel, Restaurant');
|
||||
// const [selectedStatus, setSelectedStatus] = useState('Active');
|
||||
|
||||
// useEffect(() => {
|
||||
// // Datos de prueba
|
||||
// const fakeData = [
|
||||
// {
|
||||
// name: 'Maria Fernanda López',
|
||||
// nss: '00111111',
|
||||
// position: 'Concierge',
|
||||
// area: 'Hotel',
|
||||
// phone: '33-33-33-33-33',
|
||||
// endDate: '17/01/2025',
|
||||
// status: 'ACTIVE',
|
||||
// },
|
||||
// {
|
||||
// name: 'José Ángel Ramírez',
|
||||
// nss: '00111111',
|
||||
// position: 'Chef',
|
||||
// area: 'Restaurant',
|
||||
// phone: '33-33-33-33-33',
|
||||
// endDate: '15/01/2025',
|
||||
// status: 'ACTIVE',
|
||||
// },
|
||||
// ];
|
||||
// setEmployees(fakeData);
|
||||
// }, []);
|
||||
|
||||
// const columns = [
|
||||
// { header: 'NAME', key: 'name' },
|
||||
// { header: 'NSS', key: 'nss' },
|
||||
// { header: 'POSITION', key: 'position' },
|
||||
// { header: 'AREA', key: 'area' },
|
||||
// { header: 'PHONE', key: 'phone' },
|
||||
// { header: 'END OF CONTRACT', key: 'endDate' },
|
||||
// { header: 'STATUS', key: 'status' },
|
||||
// ];
|
||||
|
||||
// return (
|
||||
// <div>
|
||||
// <h2>Employees</h2>
|
||||
|
||||
// {/* Filtros */}
|
||||
// <Filters
|
||||
// areaOptions={['Hotel, Restaurant']}
|
||||
// statusOptions={['Active', 'Inactive']}
|
||||
// selectedArea={selectedArea}
|
||||
// selectedStatus={selectedStatus}
|
||||
// onAreaChange={(e) => setSelectedArea(e.target.value)}
|
||||
// onStatusChange={(e) => setSelectedStatus(e.target.value)}
|
||||
// />
|
||||
|
||||
// {/* Tabla */}
|
||||
// <Table columns={columns} data={employees} />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
/**Reemplazar fakeData por */
|
||||
// const data = await getEmployees();
|
||||
// setEmployees(data);
|
||||
|
||||
|
||||
|
||||
/**Sin los datos de Prueba */
|
||||
// import React, { useEffect, useState } from 'react';
|
||||
// import { getEmployees } from '../services/employeeService';
|
||||
// import Table from '../components/Table/HotelTable';
|
||||
|
||||
// export default function Employees() {
|
||||
// const [employees, setEmployees] = useState([]);
|
||||
|
||||
// useEffect(() => {
|
||||
// async function fetchData() {
|
||||
// const data = await getEmployees();
|
||||
// setEmployees(data);
|
||||
// }
|
||||
|
||||
// fetchData();
|
||||
// }, []);
|
||||
|
||||
// const columns = [
|
||||
// { header: 'NAME', key: 'name' },
|
||||
// { header: 'NSS', key: 'nss' },
|
||||
// { header: 'POSITION', key: 'position' },
|
||||
// { header: 'AREA', key: 'area' },
|
||||
// { header: 'PHONE', key: 'phone' },
|
||||
// { header: 'END OF CONTRACT', key: 'endDate' },
|
||||
// { header: 'STATUS', key: 'status' },
|
||||
// ];
|
||||
|
||||
// return (
|
||||
// <div>
|
||||
// <h2>Employees</h2>
|
||||
// <Table columns={columns} data={employees} />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
182
frontend/Frontend-Hotel/src/pages/Expenses/EditExpense.css
Normal file
182
frontend/Frontend-Hotel/src/pages/Expenses/EditExpense.css
Normal file
@@ -0,0 +1,182 @@
|
||||
.new-expense-container {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-id {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background-color: #6c757d;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.back-button:active {
|
||||
background-color: #545b62;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
/* grid-template-columns: repeat(3, 1fr); */
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-grid label {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-grid input,
|
||||
.form-grid select {
|
||||
width: 100%;
|
||||
padding: 6px 4px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.save-button-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background-color: #ffcb05;
|
||||
color: #fff;
|
||||
padding: 10px 30px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background-color: #f4b400;
|
||||
}
|
||||
|
||||
.product-table-section {
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
|
||||
/*Actualización de New Expense con tabla*/
|
||||
|
||||
/* src/pages/Expenses/NewExpense.css */
|
||||
|
||||
.new-expense-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.form-id {
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* .form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
} */
|
||||
|
||||
.save-button-wrapper {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background-color: #ffc107; /* amarillo/dorado */
|
||||
color: #000; /* ajustar según contraste */
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.product-table-section {
|
||||
margin-top: 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.product-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.product-table th, .product-table td {
|
||||
border: 1px solid #d4af37; /* dorado */
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.product-table th {
|
||||
background-color: #800020; /* bordo/morado oscuro, según tus imágenes */
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.add-product-row {
|
||||
cursor: pointer;
|
||||
color: #d4af37; /* dorado */
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tax-pill {
|
||||
background-color: white;
|
||||
border: 1px solid #d4af37;
|
||||
border-radius: 12px;
|
||||
padding: 2px 6px;
|
||||
color: #800020;
|
||||
}
|
||||
|
||||
.product-table tfoot td {
|
||||
border-top: 2px solid #d4af37;
|
||||
padding: 8px;
|
||||
}
|
||||
774
frontend/Frontend-Hotel/src/pages/Expenses/EditExpense.jsx
Normal file
774
frontend/Frontend-Hotel/src/pages/Expenses/EditExpense.jsx
Normal file
@@ -0,0 +1,774 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import './EditExpense.css';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Table from '../../components/Table/HotelTable';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
|
||||
export default function EditExpense() {
|
||||
const { id } = useParams();
|
||||
const { lang } = useContext(langContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
up_description: '',
|
||||
up_id_suppliers: '',
|
||||
up_request_date: '',
|
||||
up_payment_deadline: '',
|
||||
request_by: '',
|
||||
area: '',
|
||||
expense_cat: '',
|
||||
currency_id: '',
|
||||
needtoapprove: true,
|
||||
totalAmount: 0,
|
||||
subtotal: 0, //------
|
||||
products: []
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(true); // Start with loading: true
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const [savedExpense, setSavedExpense] = useState(null);
|
||||
|
||||
// estados para datos de selects
|
||||
const [suppliers, setSuppliers] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [areas, setAreas] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [currencies, setCurrencies] = useState([]);
|
||||
const [taxes, setTaxes] = useState([]);
|
||||
const [listproducts, setListProducts] = useState([]);
|
||||
|
||||
const formatDate = (isoDate) => {
|
||||
if (!isoDate) return '';
|
||||
const d = new Date(isoDate);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSelectData() {
|
||||
try {
|
||||
const res = await fetch(import.meta.env.VITE_API_BASE_URL + '/expenses/getinfo', {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (!res.ok) throw new Error(`Error fetching info: ${res.status}`);
|
||||
const data = await res.json();
|
||||
// Supongo que data tiene algo como:
|
||||
// { suppliers: [...], users: [...], areas: [...], categories: [...], currencies: [...] }
|
||||
setSuppliers(data.suppliers || []);
|
||||
setUsers(data.users || []);
|
||||
setAreas(data.areas || []);
|
||||
setCategories(data.categories || []);
|
||||
setCurrencies(data.currencies || []);
|
||||
setTaxes(data.tax || []);
|
||||
} catch (err) {
|
||||
console.error('Error cargando metadata (getinfo):', err);
|
||||
setError('No se pudo cargar opciones de formulario.');
|
||||
}
|
||||
}
|
||||
// Helper para cargar opciones de selects
|
||||
const fetchOptions = (url, setter, mapFn) => {
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + url)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const items = data.data || data.currency || data.categoryex || data.approve || data.request;
|
||||
if (items) {
|
||||
setter(items.map(mapFn));
|
||||
}
|
||||
})
|
||||
.catch(err => console.error(`Error fetching ${url}`, err));
|
||||
};
|
||||
|
||||
const fetchExpense = async () => {
|
||||
try {
|
||||
const res = await fetch(import.meta.env.VITE_API_BASE_URL + `/expenses/getexpense/${id}`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const textResponse = await res.text();
|
||||
throw new Error(`HTTP ${res.status}: ${textResponse}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.data || data.data.length === 0) {
|
||||
setError('Expense not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const base = data.data[0];
|
||||
|
||||
let tax_id = null;
|
||||
|
||||
if (base.iva > 0) {
|
||||
const tax_rate = parseFloat((base.iva / base.subtotal).toFixed(2));
|
||||
const taxMap = {
|
||||
0.16: 1,
|
||||
0.08: 2,
|
||||
0.00: 3
|
||||
};
|
||||
tax_id = taxMap[tax_rate] || null;
|
||||
}
|
||||
|
||||
|
||||
setForm({
|
||||
up_description: base.description_exp || '',
|
||||
up_id_suppliers: base.suppliers_id || '',
|
||||
up_request_date: formatDate(base.request_date),
|
||||
up_payment_deadline: formatDate(base.payment_deadline),
|
||||
request_by: base.request_by,
|
||||
area: base.id_area,
|
||||
expense_cat: base.category_exp,
|
||||
currency_id: base.currency,
|
||||
needtoapprove: base.needtoapprove !== undefined ? base.needtoapprove : true,
|
||||
subtotal: base.subtotal,
|
||||
up_tax: tax_id,
|
||||
iva: base.iva,
|
||||
ieps: base.ieps,
|
||||
total: base.total,
|
||||
products: base.products
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Error fetching expense:', err);
|
||||
setError('Failed to load expense data');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
/**/
|
||||
fetchExpense();
|
||||
fetchOptions('/products', setListProducts, c => ({ id: c.id_product, name: c.name_product }));
|
||||
fetchSelectData();
|
||||
}, [id]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, type, checked, value } = e.target;
|
||||
|
||||
// Crear una copia del formulario
|
||||
const updatedForm = { ...form, [name]: type === 'checkbox' ? checked : value };
|
||||
|
||||
// Inicializar valores por si están vacíos
|
||||
const subtotal = parseFloat(updatedForm.subtotal) || 0;
|
||||
const ieps = parseFloat(updatedForm.ieps) || 0;
|
||||
|
||||
let ivaAmount = 0;
|
||||
|
||||
// Si hay productos, recalcular desde ellos
|
||||
if (updatedForm.products.length > 0) {
|
||||
updatedForm.subtotal = updatedForm.products.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.subtotal) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
ivaAmount = updatedForm.products.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.iva) || 0),
|
||||
0
|
||||
);
|
||||
} else {
|
||||
// Si no hay productos, calcular IVA a partir del subtotal y tasa
|
||||
const taxRate = parseFloat(
|
||||
taxes.find(t => t.id === parseInt(updatedForm.up_tax))?.number || 0
|
||||
);
|
||||
ivaAmount = subtotal * taxRate;
|
||||
}
|
||||
|
||||
updatedForm.iva = ivaAmount;
|
||||
|
||||
// Calcular total final (subtotal + IVA + IEPS)
|
||||
updatedForm.total = subtotal + ivaAmount + ieps;
|
||||
|
||||
// Actualizar estado
|
||||
setForm({
|
||||
...updatedForm,
|
||||
iva: updatedForm.iva.toFixed(2),
|
||||
total: updatedForm.total.toFixed(2),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const handleCategoryChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
const categoryValue = Number(value) || 0; // si el value es numérico
|
||||
setForm(prev => { // Clonamos el form actual
|
||||
const updatedForm = { ...prev, [name]: categoryValue };
|
||||
|
||||
// Find the selected category to check its name
|
||||
const selectedCategory = categories.find(c => c.id === categoryValue);
|
||||
const isSuppliesCategory = selectedCategory && (
|
||||
selectedCategory.name === "Supplies" ||
|
||||
selectedCategory.name === "Laundry Supplies" ||
|
||||
selectedCategory.id === 1
|
||||
);
|
||||
|
||||
if (isSuppliesCategory) {
|
||||
updatedForm.products = [
|
||||
{
|
||||
id_product: null,
|
||||
quantity: 1,
|
||||
unit_cost: 0,
|
||||
id_tax: null,
|
||||
total: 0
|
||||
}
|
||||
];
|
||||
updatedForm.up_tax = "";
|
||||
} else { // Si cambia a otra categoría, vaciamos los productos
|
||||
updatedForm.products = [];
|
||||
} return updatedForm;
|
||||
});
|
||||
};
|
||||
|
||||
const handleProductChange = (index, field, value) => {
|
||||
const updatedProducts = [...form.products]; // Convertir a entero si es un campo numérico esperado
|
||||
const ieps = parseFloat(form.ieps) || 0;
|
||||
const processedValue = ['unit_cost'].includes(field) ? parseFloat(value) : ['quantity', 'id_tax', 'id_product'].includes(field) ? parseInt(value) : value;
|
||||
updatedProducts[index][field] = processedValue;
|
||||
|
||||
if (field === 'quantity' || field === 'unit_cost' || field === 'id_tax') {
|
||||
const qty = parseFloat(updatedProducts[index].quantity) || 0;
|
||||
const cost = parseFloat(updatedProducts[index].unit_cost) || 0;
|
||||
const taxValue = parseFloat(taxes.find(t => t.id === parseInt(updatedProducts[index].id_tax))?.number || 0);
|
||||
updatedProducts[index].subtotal = qty * cost;
|
||||
updatedProducts[index].iva = qty * cost * taxValue;
|
||||
}
|
||||
|
||||
// Recalcular el amount (suma de todos los productos)
|
||||
const subtotalAmount = updatedProducts.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.subtotal) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Recalcular el amount (suma de todos los productos)
|
||||
const ivaAmount = updatedProducts.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.iva) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
products: updatedProducts,
|
||||
subtotal: subtotalAmount.toFixed(2), // redondeado a 2 decimales
|
||||
iva: ivaAmount.toFixed(2), // redondeado a 2 decimales
|
||||
total: (subtotalAmount + ivaAmount + ieps).toFixed(2) // Calcular total final (subtotal + IVA + IEPS)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddProduct = () => {
|
||||
const ieps = parseFloat(form.ieps) || 0;
|
||||
|
||||
// Primero, agregamos el nuevo producto
|
||||
const updatedProducts = [
|
||||
...form.products,
|
||||
{
|
||||
id_product: null,
|
||||
quantity: 1,
|
||||
unit_cost: 0,
|
||||
id_tax: '',
|
||||
subtotal: 0,
|
||||
iva: 0
|
||||
}
|
||||
];
|
||||
|
||||
// Ahora sí, recalculamos después de agregarlo
|
||||
const subtotalAmount = updatedProducts.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.subtotal) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const ivaAmount = updatedProducts.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.iva) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
products: updatedProducts,
|
||||
subtotal: subtotalAmount.toFixed(2),
|
||||
iva: ivaAmount.toFixed(2),
|
||||
total: (subtotalAmount + ivaAmount + ieps).toFixed(2)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRemoveProduct = (index) => {
|
||||
setForm(prev => {
|
||||
const updated = [...prev.products];
|
||||
updated.splice(index, 1);
|
||||
|
||||
// Recalcular el amount (suma de todos los productos)
|
||||
const subtotalAmount = updated.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.subtotal) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Recalcular el amount (suma de todos los productos)
|
||||
const ivaAmount = updated.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.iva) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const ieps = parseFloat(prev.ieps) || 0;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
products: updated,
|
||||
subtotal: subtotalAmount.toFixed(2), // redondeado a 2 decimales
|
||||
iva: ivaAmount.toFixed(2), // redondeado a 2 decimales
|
||||
total: (subtotalAmount + ivaAmount + ieps).toFixed(2) // Calcular total final (subtotal + IVA + IEPS)
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setSuccess(false);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
up_description: form.up_description,//
|
||||
suppliers_id: Number(form.up_id_suppliers), //
|
||||
up_req_date: form.up_request_date,//
|
||||
up_deadline: form.up_payment_deadline,//
|
||||
up_currency: Number(form.currency_id), //
|
||||
up_request_by: Number(form.request_by),//
|
||||
up_area: Number(form.area), //
|
||||
up_category: Number(form.expense_cat), //
|
||||
needtoapprove: form.needtoapprove,
|
||||
up_subtotal: Number(form.subtotal), //------
|
||||
up_iva: Number(form.iva), //------
|
||||
up_ieps: Number(form.ieps), //------
|
||||
up_total: Number(form.total), //
|
||||
products: form.products //
|
||||
};
|
||||
|
||||
const res = await fetch(import.meta.env.VITE_API_BASE_URL + `/expenses/updateexpense/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try { data = JSON.parse(text); } catch { data = text; }
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('Error response:', res.status, data);
|
||||
throw new Error(`Server error ${res.status}: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setSavedExpense(payload);
|
||||
|
||||
} catch (err) {
|
||||
console.error('handleSubmit error:', err);
|
||||
setError(err.message || 'Unexpected error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh'
|
||||
}}>
|
||||
<div>{lang === "en" ? "Loading expense data..." : "Cargando datos del gasto..."}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="new-expense-container">
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<div className="form-id">
|
||||
{lang === "en" ? "Edit expense (" : "Editar gasto ("}{id})
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="back-button"
|
||||
onClick={() => navigate("/app/report-expense")}
|
||||
>
|
||||
<span>←</span>
|
||||
{lang === "en" ? "Back" : "Volver"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
<label>{lang === "en" ? "Description" : "Descripción"}</label>
|
||||
<input
|
||||
name="up_description"
|
||||
value={form.up_description}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Supplier" : "Proveedor"}</label>
|
||||
<select
|
||||
name="up_id_suppliers"
|
||||
value={form.up_id_suppliers}
|
||||
onChange={handleChange}
|
||||
required
|
||||
>
|
||||
<option value="">{lang === "en" ? "Select supplier" : "Seleccionar proveedor"}</option>
|
||||
{suppliers
|
||||
.sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase();
|
||||
const nameB = (b.name || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Request by" : "Solicitado por"}</label>
|
||||
<select
|
||||
name="request_by"
|
||||
value={form.request_by}
|
||||
onChange={handleChange}
|
||||
required
|
||||
>
|
||||
<option value="">{lang === "en" ? "Select requester" : "Seleccionar solicitante"}</option>
|
||||
{users.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Request date" : "Fecha de solicitud"}</label>
|
||||
<input
|
||||
type="date"
|
||||
name="up_request_date"
|
||||
value={form.up_request_date}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Area" : "Área"}</label>
|
||||
<select
|
||||
name="area"
|
||||
value={form.area}
|
||||
onChange={handleChange}
|
||||
required
|
||||
>
|
||||
<option value="">{lang === "en" ? "Select area" : "Seleccionar área"}</option>
|
||||
{areas
|
||||
.sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase();
|
||||
const nameB = (b.name || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map(a => (
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Category" : "Categoría"}</label>
|
||||
<select
|
||||
name="expense_cat"
|
||||
value={form.expense_cat}
|
||||
onChange={handleCategoryChange}
|
||||
required
|
||||
>
|
||||
<option value="">{lang === "en" ? "Select category" : "Seleccionar categoría"}</option>
|
||||
{categories
|
||||
.sort((a, b) => {
|
||||
const nameA = (lang === "en" ? (a.name || '') : (a.spanish_name || a.name || '')).toLowerCase();
|
||||
const nameB = (lang === "en" ? (b.name || '') : (b.spanish_name || b.name || '')).toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map(c => {
|
||||
const displayName = lang === "en" ? (c.name || '') : (c.spanish_name || c.name || '');
|
||||
console.log(`EditExpense - Category ${c.id}: lang=${lang}, name=${c.name}, spanish_name=${c.spanish_name}, display=${displayName}`);
|
||||
return (
|
||||
<option key={c.id} value={c.id}>
|
||||
{displayName}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Payment deadline" : "Fecha límite de pago"}</label>
|
||||
<input
|
||||
type="date"
|
||||
name="up_payment_deadline"
|
||||
value={form.up_payment_deadline}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Currency" : "Moneda"}</label>
|
||||
<select
|
||||
name="currency_id"
|
||||
value={form.currency_id}
|
||||
onChange={handleChange}
|
||||
required
|
||||
>
|
||||
<option value="">{lang === "en" ? "Select currency" : "Seleccionar moneda"}</option>
|
||||
{currencies.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{"Subtotal"}</label>
|
||||
<input
|
||||
name="subtotal"
|
||||
value={form.subtotal}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
required
|
||||
disabled={(() => {
|
||||
const selectedCategory = categories.find(c => c.id === Number(form.expense_cat));
|
||||
return selectedCategory && (
|
||||
selectedCategory.name === "Supplies" ||
|
||||
selectedCategory.name === "Laundry Supplies" ||
|
||||
selectedCategory.id === 1
|
||||
);
|
||||
})()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const selectedCategory = categories.find(c => c.id === Number(form.expense_cat));
|
||||
const isSuppliesCategory = selectedCategory && (
|
||||
selectedCategory.name === "Supplies" ||
|
||||
selectedCategory.name === "Laundry Supplies" ||
|
||||
selectedCategory.id === 1
|
||||
);
|
||||
return !isSuppliesCategory;
|
||||
})() && (
|
||||
<div>
|
||||
<label>{lang === "en" ? "VAT rate:" : "Tasa de IVA:"}</label>
|
||||
<select name="up_tax"
|
||||
value={form.up_tax}
|
||||
onChange={handleChange}
|
||||
disabled={(() => {
|
||||
const selectedCategory = categories.find(c => c.id === Number(form.expense_cat));
|
||||
return selectedCategory && (
|
||||
selectedCategory.name === "Supplies" ||
|
||||
selectedCategory.name === "Laundry Supplies" ||
|
||||
selectedCategory.id === 1
|
||||
);
|
||||
})()}
|
||||
>
|
||||
<option value="" disabled>{lang === "en" ? "Select Tax" : "Seleccione Impuesto"}</option>
|
||||
{taxes.map(tax => (
|
||||
<option key={tax.id} value={tax.id}>{tax.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "VAT" : "IVA"}</label>
|
||||
<input
|
||||
name="iva"
|
||||
value={form.iva}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
required
|
||||
disabled={(() => {
|
||||
const selectedCategory = categories.find(c => c.id === Number(form.expense_cat));
|
||||
return selectedCategory && (
|
||||
selectedCategory.name === "Supplies" ||
|
||||
selectedCategory.name === "Laundry Supplies" ||
|
||||
selectedCategory.id === 1
|
||||
);
|
||||
})()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{"IEPS"}</label>
|
||||
<input
|
||||
name="ieps"
|
||||
value={form.ieps}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{"Total"}</label>
|
||||
<input
|
||||
name="total"
|
||||
value={form.total}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
required
|
||||
disabled={(() => {
|
||||
const selectedCategory = categories.find(c => c.id === Number(form.expense_cat));
|
||||
return selectedCategory && (
|
||||
selectedCategory.name === "Supplies" ||
|
||||
selectedCategory.name === "Laundry Supplies" ||
|
||||
selectedCategory.id === 1
|
||||
);
|
||||
})()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Need to Approve (Campo solicitado en la estructura) */}
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<label>{lang === "en" ? "Needs Approval:" : "Requiere aprobación:"}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="needtoapprove"
|
||||
checked={form.needtoapprove}
|
||||
onChange={handleChange}
|
||||
style={{ width: 'auto', marginLeft: '10px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sección de Productos */}
|
||||
{(() => {
|
||||
const selectedCategory = categories.find(c => c.id === Number(form.expense_cat));
|
||||
const isSuppliesCategory = selectedCategory && (
|
||||
selectedCategory.name === "Supplies" ||
|
||||
selectedCategory.name === "Laundry Supplies" ||
|
||||
selectedCategory.id === 1
|
||||
);
|
||||
return isSuppliesCategory;
|
||||
})() && (
|
||||
<div className="add-products">
|
||||
<h3 style={{ marginTop: '20px' }}>
|
||||
{lang === "en" ? "Products" : "Productos"}
|
||||
</h3>
|
||||
|
||||
<div style={{ border: '1px solid #ccc', padding: '10px', marginTop: '10px' }}>
|
||||
{form.products.map((product, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
marginBottom: '10px',
|
||||
borderBottom: '1px dotted #eee',
|
||||
paddingBottom: '5px',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label>{lang === "en" ? "Product ID:" : "ID Producto:"}</label>
|
||||
<select name="product_id"
|
||||
value={product.id_product || ""}
|
||||
onChange={(e) => handleProductChange(index, 'id_product', e.target.value)}>
|
||||
<option value="" disabled>{lang === "en" ? "Select Product ID" : "Seleccione ID Producto"}</option>
|
||||
{[...listproducts].sort((a, b) => a.name.localeCompare(b.name)).map(lp => (
|
||||
<option key={lp.id} value={lp.id}>{lp.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "en" ? "Quantity:" : "Cantidad:"}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={product.quantity}
|
||||
onChange={(e) => handleProductChange(index, 'quantity', e.target.value)}
|
||||
placeholder={lang === "en" ? "Quantity" : "Cantidad"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "en" ? "Unit Cost:" : "Precio Unitario:"}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={product.unit_cost}
|
||||
onChange={(e) => handleProductChange(index, 'unit_cost', e.target.value)}
|
||||
placeholder={lang === "en" ? "Unit Cost" : "Precio Unitario"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "en" ? "Tax:" : "Impuesto:"}</label>
|
||||
<select name="product_id_tax"
|
||||
value={product.id_tax}
|
||||
onChange={(e) => handleProductChange(index, 'id_tax', e.target.value)}>
|
||||
<option value="" disabled>{lang === "en" ? "Select Tax" : "Seleccione Impuesto"}</option>
|
||||
{taxes.map(tax => (
|
||||
<option key={tax.id} value={tax.id}>{tax.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "end", gap: "10px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveProduct(index)}
|
||||
className="save-button"
|
||||
disabled={form.products.length <= 1}
|
||||
>
|
||||
{lang === "en" ? "Remove" : "Eliminar"}
|
||||
</button>
|
||||
{index === form.products.length - 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddProduct}
|
||||
className="save-button"
|
||||
>
|
||||
{lang === "en" ? "Add Product" : "Agregar producto"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Botón de Actualización que dispara la petición PUT */}
|
||||
<div style={{ marginTop: '30px', textAlign: 'center' }}>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
fontSize: '16px',
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{lang === "en" ? "Update Expense 💾" : "Actualizar gasto 💾"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<span style={{ color: 'green' }}>
|
||||
{lang === "en" ? "Expense saved successfully!" : "¡Gasto guardado con éxito!"}
|
||||
</span>
|
||||
)}
|
||||
{error && <span style={{ color: 'red' }}>{error}</span>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
frontend/Frontend-Hotel/src/pages/Expenses/ExpenseDetail.css
Normal file
201
frontend/Frontend-Hotel/src/pages/Expenses/ExpenseDetail.css
Normal file
@@ -0,0 +1,201 @@
|
||||
|
||||
.expense-detail {
|
||||
background-color: #fff;
|
||||
padding: 2rem;
|
||||
font-family: 'Montserrat';
|
||||
max-width: 1000px;
|
||||
margin: 2rem auto;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.expense-detail h2 {
|
||||
color: #000;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.expense-detail h3 {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.expense-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.expense-detail label {
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
margin-bottom: 0.4rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.expense-detail p {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.expense-detail input[type="text"],
|
||||
.expense-detail input[type="number"],
|
||||
.expense-detail input[type="date"] {
|
||||
width: 90%;
|
||||
max-width: 280px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-family: 'Montserrat';
|
||||
background-color: #f9f9f9;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.expense-detail input:focus {
|
||||
outline: none;
|
||||
border-color: #f1c40f;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 0 2px rgba(241, 196, 15, 0.2);
|
||||
}
|
||||
|
||||
.expense-detail button {
|
||||
margin-top: 2rem;
|
||||
padding: 12px 24px;
|
||||
background-color: #f1c40f;
|
||||
border: none;
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.expense-detail button:hover {
|
||||
background-color: #d4ac0d;
|
||||
}
|
||||
|
||||
.products-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.products-section h3 {
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.product-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
border: 1px solid #f1c40f;
|
||||
}
|
||||
|
||||
.product-table th,
|
||||
.product-table td {
|
||||
border: 1px solid #f1c40f;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.product-table th {
|
||||
background-color: #800020;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-table td {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.product-table input[type="number"] {
|
||||
width: 80px;
|
||||
padding: 6px;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: green;
|
||||
margin-top: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
|
||||
/* .expense-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
} */
|
||||
|
||||
/* .expense-detail-container {
|
||||
>>>>>>> Stashed changes
|
||||
padding: 2rem;
|
||||
max-width: 900px;
|
||||
margin: auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.expense-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
flex: 1 1 45%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: 0.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input, select {
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 2rem;
|
||||
background-color: #f4b400;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.amount-warning-banner {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeeba;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.amount-warning-banner::before {
|
||||
content: "⚠️";
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
217
frontend/Frontend-Hotel/src/pages/Expenses/ExpenseDetail.jsx
Normal file
217
frontend/Frontend-Hotel/src/pages/Expenses/ExpenseDetail.jsx
Normal file
@@ -0,0 +1,217 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
import './ExpenseDetail.css';
|
||||
|
||||
export default function ExpenseDetail() {
|
||||
const { id } = useParams();
|
||||
const { lang } = useContext(langContext);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
up_description: '',
|
||||
up_id_suppliers: '',
|
||||
up_request_by: '',
|
||||
up_area: '',
|
||||
up_category: '',
|
||||
up_req_date: '',
|
||||
up_deadline: '',
|
||||
up_currency: '',
|
||||
up_approbal_by:'',
|
||||
needtoapprove: true,
|
||||
amount: '',
|
||||
products: []
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [suppliers, setSuppliers] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [areas, setAreas] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [currencies, setCurrencies] = useState([]);
|
||||
const [taxes, setTaxes] = useState([]);
|
||||
|
||||
const formatDate = (isoDate) => {
|
||||
if (!isoDate) return '';
|
||||
const d = new Date(isoDate);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
// Cargar listas de referencia (suppliers, users, areas, etc.)
|
||||
useEffect(() => {
|
||||
async function fetchSelectData() {
|
||||
try {
|
||||
const res = await fetch(import.meta.env.VITE_API_BASE_URL + '/expenses/getinfo', {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (!res.ok) throw new Error(`Error fetching info: ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
setSuppliers(data.suppliers || []);
|
||||
setUsers(data.users || []);
|
||||
setAreas(data.areas || []);
|
||||
setCategories(data.categories || []);
|
||||
setCurrencies(data.currencies || []);
|
||||
setTaxes(data.tax || []);
|
||||
} catch (err) {
|
||||
console.error('Error cargando metadata (getinfo):', err);
|
||||
setError('No se pudo cargar opciones de formulario.');
|
||||
}
|
||||
}
|
||||
|
||||
fetchSelectData();
|
||||
}, []);
|
||||
|
||||
// Cargar datos del gasto
|
||||
useEffect(() => {
|
||||
async function fetchExpense() {
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/expenses/getexpense/${id}`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.data || data.data.length === 0) {
|
||||
setError('No se encontraron datos del gasto.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const base = data.data[0];
|
||||
setForm({
|
||||
up_description: base.description_exp || '',
|
||||
up_id_suppliers: base.suppliers_id || '',
|
||||
up_request_by: base.request_by || '',
|
||||
up_area: base.id_area || '',
|
||||
up_category: base.category_exp || '',
|
||||
up_req_date: formatDate(base.request_date),
|
||||
up_deadline: formatDate(base.payment_deadline),
|
||||
up_currency: base.currency || '',
|
||||
up_approbal_by: base.approval_by || '',
|
||||
needtoapprove: base.needtoapprove !== undefined ? base.needtoapprove : true,
|
||||
amount: base.amount || '',
|
||||
products: base.products || []
|
||||
});
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Error cargando gasto:', err);
|
||||
setError('No se pudo cargar el gasto.');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchExpense();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div style={{ textAlign: 'center', padding: '20px' }}>Loading...</div>;
|
||||
if (error) return <div style={{ color: 'red', textAlign: 'center', padding: '20px' }}>{error}</div>;
|
||||
|
||||
return (
|
||||
<div className="expense-detail">
|
||||
<h2>{lang === "en" ? "Detail" : "Información"} (ID: {id})</h2>
|
||||
|
||||
<div className="expense-detail-grid">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label>{lang === "en" ? "Description" : "Descripción"}</label>
|
||||
<p>{form.up_description}</p>
|
||||
</div>
|
||||
|
||||
{/* Supplier */}
|
||||
<div>
|
||||
<label>{lang === "en" ? "Supplier" : "Proveedor"}</label>
|
||||
<p>{suppliers.find(s => String(s.id) === String(form.up_id_suppliers))?.name || form.up_id_suppliers}</p>
|
||||
</div>
|
||||
|
||||
{/* Request By */}
|
||||
<div>
|
||||
<label>{lang === "en" ? "Request by" : "Solicitado por"}</label>
|
||||
<p>{users.find(u => String(u.id) === String(form.up_request_by))?.name || form.up_request_by}</p>
|
||||
</div>
|
||||
|
||||
{/* Area */}
|
||||
<div>
|
||||
<label>{lang === "en" ? "Area" : "Área"}</label>
|
||||
<p>{areas.find(a => String(a.id) === String(form.up_area))?.name || form.up_area}</p>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label>{lang === "en" ? "Category" : "Categoría"}</label>
|
||||
<p>{categories.find(c => String(c.id) === String(form.up_category))?.name || form.up_category}</p>
|
||||
</div>
|
||||
|
||||
{/* Request Date */}
|
||||
<div>
|
||||
<label>{lang === "en" ? "Request Date" : "Fecha de solicitud"}</label>
|
||||
<p>{form.up_req_date}</p>
|
||||
</div>
|
||||
|
||||
{/* Payment Deadline */}
|
||||
<div>
|
||||
<label>{lang === "en" ? "Payment deadline" : "Fecha límite de pago"}</label>
|
||||
<p>{form.up_deadline}</p>
|
||||
</div>
|
||||
|
||||
{/* Currency */}
|
||||
<div>
|
||||
<label>{lang === "en" ? "Currency" : "Moneda"}</label>
|
||||
<p>{currencies.find(c => String(c.id) === String(form.up_currency))?.name || form.up_currency}</p>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div>
|
||||
<label>{lang === "en" ? "Amount" : "Importe"}</label>
|
||||
<p>{form.amount}</p>
|
||||
</div>
|
||||
|
||||
{/* Needs Approval */}
|
||||
<div>
|
||||
<label>{lang === "en" ? "Needs Approval" : "Requiere aprobación"}</label>
|
||||
<p>{form.needtoapprove ? (lang === "en" ? 'Yes' : 'Sí') : (lang === "en" ? 'No' : 'No')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
{/* Products */}
|
||||
<h3>{lang === "en" ? "Products" : "Productos"}</h3>
|
||||
<div style={{ border: '1px solid #ccc', padding: '10px', marginTop: '10px' }}>
|
||||
{form.products.length > 0 ? (
|
||||
form.products.map((product, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
marginBottom: '10px',
|
||||
borderBottom: '1px dotted #eee',
|
||||
paddingBottom: '5px',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label>{lang === "en" ? "Product" : "Producto"}</label>
|
||||
<p>{product.name || product.id_product}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "en" ? "Quantity" : "Cantidad"}</label>
|
||||
<p>{product.quantity}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "en" ? "Tax" : "Impuesto"}</label>
|
||||
<p>{taxes.find(t => String(t.id) === String(product.id_tax))?.name || product.id_tax}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p>{lang === "en" ? "No products loaded." : "No hay productos cargados."}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
.monthly-payments-page .page-filters {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.monthly-payments-page .filters-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.monthly-payments-page .filters-row.second-row {
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.monthly-payments-page .filters-row.second-row .filter-select {
|
||||
flex: 0 0 auto;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.monthly-payments-page .filters-row.second-row .clear-filters-btn {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.monthly-payments-page .filters-row .filter-search {
|
||||
flex: 1;
|
||||
max-width: 280px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.monthly-payments-page .page-filters {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.monthly-payments-page .filters-row {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.monthly-payments-page .filters-row.second-row {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.monthly-payments-page .filter-search {
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.monthly-payments-page .filter-select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.monthly-payments-page .clear-filters-btn {
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
}
|
||||
}
|
||||
436
frontend/Frontend-Hotel/src/pages/Expenses/MonthlyPayments.jsx
Normal file
436
frontend/Frontend-Hotel/src/pages/Expenses/MonthlyPayments.jsx
Normal file
@@ -0,0 +1,436 @@
|
||||
import React, { useEffect, useState, useContext } from 'react';
|
||||
import Table from '../../components/Table/HotelTable';
|
||||
import Switch from '../../components/Switch';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
import ConfirmationModal from '../../components/Modals/ConfirmationMontlyPay';
|
||||
import '../../components/Filters/Filters.css';
|
||||
import './MonthlyPayments.css';
|
||||
|
||||
export default function MonthlyPayments() {
|
||||
const [payments, setPayments] = useState([]);
|
||||
const [switchStates, setSwitchStates] = useState({});
|
||||
const [descriptionSearch, setDescriptionSearch] = useState('');
|
||||
const [areaFilter, setAreaFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [paymentStatusFilter, setPaymentStatusFilter] = useState('');
|
||||
const [supplierFilter, setSupplierFilter] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const { lang } = useContext(langContext);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [taxes, setTaxes] = useState([]);
|
||||
const [selectedDescription, setselectedDescription] = useState([]);
|
||||
const [initialAmount, setInitialAmount] = useState(null);
|
||||
const [initialTaxId, setInitialTaxId] = useState(null);
|
||||
const [isFixedPayment, setIsFixedPayment] = useState(false);
|
||||
|
||||
const refreshMonthlyData = async () => {
|
||||
try {
|
||||
await fetch(`${import.meta.env.VITE_API_BASE_URL}/payment/refreshmonthly`, {
|
||||
method: 'GET'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error refreshing monthly data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = payments.filter((item) => {
|
||||
const matchesDescription = item.description.toLowerCase().includes(descriptionSearch.toLowerCase());
|
||||
const matchesArea = !areaFilter || item.area === areaFilter;
|
||||
const matchesStatus = !statusFilter || item.status === statusFilter;
|
||||
const matchesPaymentStatus = !paymentStatusFilter || item.paymentStatus === paymentStatusFilter;
|
||||
const matchesSupplier = !supplierFilter || item.supplier === supplierFilter;
|
||||
const isPending = item.paymentStatus === "PENDING";
|
||||
|
||||
return matchesDescription && matchesArea && matchesStatus && matchesPaymentStatus && matchesSupplier && isPending;
|
||||
});
|
||||
|
||||
const uniqueAreas = [...new Set(payments.map(item => item.area).filter(Boolean))].sort();
|
||||
const uniqueStatuses = [...new Set(payments.map(item => item.status).filter(Boolean))].sort();
|
||||
const uniquePaymentStatuses = [...new Set(payments.map(item => item.paymentStatus).filter(Boolean))].sort();
|
||||
const uniqueSuppliers = [...new Set(payments.map(item => item.supplier).filter(Boolean))].sort();
|
||||
|
||||
const clearFilters = () => {
|
||||
setDescriptionSearch('');
|
||||
setAreaFilter('');
|
||||
setStatusFilter('');
|
||||
setPaymentStatusFilter('');
|
||||
setSupplierFilter('');
|
||||
};
|
||||
|
||||
const handleSwitchToggle = async (expenseId) => {
|
||||
const newState = !switchStates[expenseId];
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/payment/needtorefresh/${expenseId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
notrefresh: newState
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Error updating refresh status");
|
||||
}
|
||||
|
||||
const newStates = {
|
||||
...switchStates,
|
||||
[expenseId]: newState
|
||||
};
|
||||
setSwitchStates(newStates);
|
||||
|
||||
localStorage.setItem('recurrenceSwitchStates', JSON.stringify(newStates));
|
||||
|
||||
await refreshTableData();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error updating refresh status:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshTableData = async () => {
|
||||
try {
|
||||
const response = await fetch(import.meta.env.VITE_API_BASE_URL + '/expenses/monthlypayments');
|
||||
const data = await response.json();
|
||||
|
||||
const groupedDataObj = data.data.reduce((acc, item) => {
|
||||
const key = `${item.expense_description}-${item.area}`;
|
||||
const itemDate = new Date(item.payment_deadline);
|
||||
|
||||
if (!acc[key] || itemDate > new Date(acc[key].payment_deadline)) {
|
||||
acc[key] = item;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const adaptedData = Object.values(groupedDataObj).map((item) => ({
|
||||
id: item.id_expense,
|
||||
description: item.expense_description,
|
||||
date: new Date(item.payment_deadline).toISOString().split('T')[0],
|
||||
area: item.area.toUpperCase(),
|
||||
recurrence: item.recurrence_name,
|
||||
supplier: item.supplier,
|
||||
status: capitalizeFirstLetter(item.status),
|
||||
amount: `$${parseFloat(item.total || 0).toLocaleString()}`,
|
||||
paymentStatus: item.status_payment.toUpperCase(),
|
||||
more: { id: item.id_expense, description: item.expense_description, status: item.status_payment }
|
||||
}));
|
||||
|
||||
const groupedData = adaptedData.sort((a, b) => {
|
||||
if (a.paymentStatus === "PENDING" && b.paymentStatus !== "PENDING") return -1;
|
||||
if (a.paymentStatus !== "PENDING" && b.paymentStatus === "PENDING") return 1;
|
||||
|
||||
const descCompare = a.description.localeCompare(b.description);
|
||||
if (descCompare !== 0) return descCompare;
|
||||
|
||||
const areaCompare = a.area.localeCompare(b.area);
|
||||
if (areaCompare !== 0) return areaCompare;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
setPayments(groupedData);
|
||||
} catch (error) {
|
||||
console.error('Error refreshing table data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenModal = async (more) => {
|
||||
const {id, description} = more;
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/payment/onemothlyexpense/${id}`);
|
||||
const data = await response.json();
|
||||
setSelectedId(id);
|
||||
setselectedDescription(description);
|
||||
|
||||
if (data.data && data.data[0] && data.data[0].payment_type === 1) {
|
||||
setInitialAmount(data.data[0].new_subtotal);
|
||||
setInitialTaxId(data.data[0].tax_id);
|
||||
setIsFixedPayment(true);
|
||||
} else {
|
||||
setInitialAmount(null);
|
||||
setInitialTaxId(null);
|
||||
setIsFixedPayment(false);
|
||||
}
|
||||
|
||||
setModalOpen(true);
|
||||
};
|
||||
const handleConfirm = async (taxid, amount) => {
|
||||
console.log(taxid, amount, selectedId);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/payment/paymentstatusmonthly/${selectedId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status_payment: 1,
|
||||
tax_id: taxid,
|
||||
subtotal: amount,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Error al aplicar el cambio");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("✅ Respuesta del servidor:", data);
|
||||
|
||||
await refreshMonthlyData();
|
||||
|
||||
await refreshTableData();
|
||||
|
||||
} catch (err) {
|
||||
console.error("❌ Error en handleConfirm:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
await refreshMonthlyData();
|
||||
|
||||
async function MP() {
|
||||
await fetch(import.meta.env.VITE_API_BASE_URL + '/expenses/monthlypayments')
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
const groupedDataObj = data.data.reduce((acc, item) => {
|
||||
const key = `${item.expense_description}-${item.area}`;
|
||||
const itemDate = new Date(item.payment_deadline);
|
||||
|
||||
if (!acc[key] || itemDate > new Date(acc[key].payment_deadline)) {
|
||||
acc[key] = item;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const adaptedData = Object.values(groupedDataObj).map((item) => ({
|
||||
id: item.id_expense,
|
||||
description: item.expense_description,
|
||||
date: new Date(item.payment_deadline).toISOString().split('T')[0],
|
||||
area: item.area.toUpperCase(),
|
||||
recurrence: item.recurrence_name,
|
||||
supplier: item.supplier,
|
||||
status: capitalizeFirstLetter(item.status),
|
||||
amount: `$${parseFloat(item.total || 0).toLocaleString()}`,
|
||||
paymentStatus: item.status_payment.toUpperCase(),
|
||||
more: { id: item.id_expense, description: item.expense_description, status: item.status_payment }
|
||||
}));
|
||||
|
||||
const groupedData = adaptedData.sort((a, b) => {
|
||||
if (a.paymentStatus === "PENDING" && b.paymentStatus !== "PENDING") return -1;
|
||||
if (a.paymentStatus !== "PENDING" && b.paymentStatus === "PENDING") return 1;
|
||||
|
||||
const descCompare = a.description.localeCompare(b.description);
|
||||
if (descCompare !== 0) return descCompare;
|
||||
|
||||
const areaCompare = a.area.localeCompare(b.area);
|
||||
if (areaCompare !== 0) return areaCompare;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
setPayments(groupedData);
|
||||
|
||||
const savedStates = JSON.parse(localStorage.getItem('recurrenceSwitchStates') || '{}');
|
||||
const initialStates = {};
|
||||
groupedData.forEach(expense => {
|
||||
initialStates[expense.id] = savedStates[expense.id] !== undefined ? savedStates[expense.id] : true;
|
||||
});
|
||||
setSwitchStates(initialStates);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching monthly payments:', error);
|
||||
});
|
||||
}
|
||||
async function Impuestos() {
|
||||
await fetch(import.meta.env.VITE_API_BASE_URL + '/expenses/getinfo')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setTaxes(data.tax);
|
||||
})
|
||||
.catch(err => console.error('Error fetching suppliers', err));
|
||||
}
|
||||
|
||||
MP();
|
||||
Impuestos();
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: lang === "en" ? "ACTIVE" : "ACTIVO",
|
||||
key: 'active',
|
||||
render: (value, row) => (
|
||||
<Switch
|
||||
checked={switchStates[row.id] ?? true}
|
||||
onChange={() => handleSwitchToggle(row.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ header: lang === "en" ? "EXPENSE DESCRIPTION" : "DESCRIPCIÓN DEL GASTO", key: 'description' },
|
||||
{
|
||||
header: lang === "en" ? "PAYMENT DEADLINE" : "FECHA LÍMITE DE PAGO",
|
||||
key: 'date',
|
||||
render: (date, row) => (
|
||||
<span style={{ color: row.status.toLowerCase() === 'delay' ? 'red' : 'inherit' }}>
|
||||
{date}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{ header: lang === "en" ? "AREA" : "ÁREA", key: 'area' },
|
||||
{ header: lang === "en" ? "RECURRENCE" : "RECURRENCIA", key: 'recurrence' },
|
||||
{ header: lang === "en" ? "SUPPLIER" : "PROVEEDOR", key: 'supplier' },
|
||||
{ header: lang === "en" ? "STATUS" : "ESTADO", key: 'status' },
|
||||
{ header: lang === "en" ? "AMOUNT" : "IMPORTE", key: 'amount' },
|
||||
{
|
||||
header: lang === "en" ? "PAYMENT STATUS" : "ESTADO DE PAGO",
|
||||
key: 'paymentStatus',
|
||||
headerStyle: { textAlign: 'center' },
|
||||
render: (status) => (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<button className={`status-button ${status.toLowerCase()}`}>
|
||||
{status}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: lang === "en" ? "PAID" : "PAGAR",
|
||||
key: 'more',
|
||||
headerStyle: { textAlign: 'center' },
|
||||
render: (more) => (
|
||||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'center' }}>
|
||||
<button
|
||||
className={more.status !== "PAID" && more.status !== "REJECTED" ? "status-badge active" : "status-badge deactive"}
|
||||
onClick={() => handleOpenModal(more)}
|
||||
disabled={more.status === "PAID" || more.status === "REJECTED"}
|
||||
>
|
||||
{lang === "en" ? "PAID" : "PAGAR"}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
function capitalizeFirstLetter(string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="monthly-payments-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
{lang === "en" ? "Monthly payments" : "Pagos mensuales"}
|
||||
</h2>
|
||||
<button
|
||||
className="new-payment-btn"
|
||||
onClick={() => navigate('/app/new-monthly')}
|
||||
>
|
||||
{lang === "en" ? "New monthly payment" : "Nuevo pago mensual"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="page-filters">
|
||||
<div className="filters-row">
|
||||
<input
|
||||
type="text"
|
||||
value={descriptionSearch}
|
||||
onChange={(e) => setDescriptionSearch(e.target.value)}
|
||||
placeholder={lang === "en" ? "Search by description..." : "Buscar por descripción..."}
|
||||
className="filter-search"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={areaFilter}
|
||||
onChange={(e) => setAreaFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "en" ? "All Areas" : "Todas las Áreas"}</option>
|
||||
{uniqueAreas.map((area, index) => (
|
||||
<option key={index} value={area}>{area}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "en" ? "All Statuses" : "Todos los Estados"}</option>
|
||||
{uniqueStatuses.map((status, index) => (
|
||||
<option key={index} value={status}>{status}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filters-row second-row">
|
||||
<select
|
||||
value={paymentStatusFilter}
|
||||
onChange={(e) => setPaymentStatusFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "en" ? "All Payment Statuses" : "Todos los Estados de Pago"}</option>
|
||||
{uniquePaymentStatuses.map((status, index) => (
|
||||
<option key={index} value={status}>{status}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={supplierFilter}
|
||||
onChange={(e) => setSupplierFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "en" ? "All Suppliers" : "Todos los Proveedores"}</option>
|
||||
{uniqueSuppliers.map((supplier, index) => (
|
||||
<option key={index} value={supplier}>{supplier}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="clear-filters-btn"
|
||||
>
|
||||
{lang === "en" ? "Clear Filters" : "Limpiar Filtros"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-section">
|
||||
<Table columns={columns} data={filteredData} />
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={modalOpen}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
description={selectedDescription}
|
||||
taxes={taxes}
|
||||
initialAmount={initialAmount}
|
||||
initialTaxId={initialTaxId}
|
||||
isFixedPayment={isFixedPayment}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
frontend/Frontend-Hotel/src/pages/Expenses/MonthlyReport.css
Normal file
221
frontend/Frontend-Hotel/src/pages/Expenses/MonthlyReport.css
Normal file
@@ -0,0 +1,221 @@
|
||||
.monthly-report-page .filters-section {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.monthly-report-page .filters-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.monthly-report-page .filters-row.second-row {
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.monthly-report-page .filters-row.second-row .filter-select {
|
||||
flex: 0 0 auto;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.monthly-report-page .filters-row.second-row .clear-filters-btn {
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
align-self: center;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.monthly-report-page .filters-row.second-row .clear-filters-btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.monthly-report-page .filters-row.second-row .clear-filters-btn:hover {
|
||||
box-shadow: 0 0 0 2px #e6e6e6;
|
||||
}
|
||||
|
||||
.monthly-report-page .filters-row .filter-search {
|
||||
flex: 0 1 280px;
|
||||
max-width: 280px;
|
||||
min-width: 180px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.monthly-report-page .filters-row .filter-search:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.monthly-report-page .filters-row .filter-search:hover {
|
||||
box-shadow: 0 0 0 2px #e6e6e6;
|
||||
}
|
||||
|
||||
.monthly-report-page .date-input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.monthly-report-page .date-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.monthly-report-page .date-input {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.monthly-report-page .date-input:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.monthly-report-page .date-input:hover {
|
||||
box-shadow: 0 0 0 2px #e6e6e6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.monthly-report-page .filters-section {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.monthly-report-page .filters-row {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.monthly-report-page .filters-row.second-row {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.monthly-report-page .filter-search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 100%;
|
||||
order: -1;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.monthly-report-page .filter-search:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.monthly-report-page .filter-search:hover {
|
||||
box-shadow: 0 0 0 2px #e6e6e6;
|
||||
}
|
||||
|
||||
|
||||
.monthly-report-page .filter-select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.monthly-report-page .date-input-group {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.monthly-report-page .date-inputs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.monthly-report-page .date-input {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.monthly-report-page .clear-filters-btn {
|
||||
width: 100%;
|
||||
align-self: center;
|
||||
margin-top: 12px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.monthly-report-page .clear-filters-btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.monthly-report-page .clear-filters-btn:hover {
|
||||
box-shadow: 0 0 0 2px #e6e6e6;
|
||||
}
|
||||
}
|
||||
311
frontend/Frontend-Hotel/src/pages/Expenses/MonthlyReport.jsx
Normal file
311
frontend/Frontend-Hotel/src/pages/Expenses/MonthlyReport.jsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import React, { useEffect, useState, useContext } from "react";
|
||||
import Table from "../../components/Table/HotelTable";
|
||||
import { langContext } from "../../context/LenguageContext";
|
||||
import "../../components/Filters/Filters.css";
|
||||
import "./MonthlyReport.css";
|
||||
|
||||
export default function MonthlyReport() {
|
||||
const { lang } = useContext(langContext);
|
||||
|
||||
const getCurrentMonthRange = () => {
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
return {
|
||||
start: firstDay.toISOString().split("T")[0],
|
||||
end: lastDay.toISOString().split("T")[0],
|
||||
};
|
||||
};
|
||||
|
||||
const currentMonth = getCurrentMonthRange();
|
||||
|
||||
const [payments, setPayments] = useState([]);
|
||||
const [descriptionSearch, setDescriptionSearch] = useState("");
|
||||
const [areaFilter, setAreaFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [paymentStatusFilter, setPaymentStatusFilter] = useState("");
|
||||
const [supplierFilter, setSupplierFilter] = useState("");
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: currentMonth.start,
|
||||
to: currentMonth.end,
|
||||
});
|
||||
|
||||
const filteredData = payments.filter((item) => {
|
||||
const matchesDescription = item.description
|
||||
.toLowerCase()
|
||||
.includes(descriptionSearch.toLowerCase());
|
||||
const matchesArea = !areaFilter || item.area === areaFilter;
|
||||
const matchesStatus = !statusFilter || item.status === statusFilter;
|
||||
const matchesPaymentStatus =
|
||||
!paymentStatusFilter || item.paymentStatus === paymentStatusFilter;
|
||||
const matchesSupplier = !supplierFilter || item.supplier === supplierFilter;
|
||||
|
||||
let matchesDateRange = true;
|
||||
if (dateRange.from || dateRange.to) {
|
||||
if (!item.date) {
|
||||
matchesDateRange = false;
|
||||
} else {
|
||||
const itemDate = new Date(item.date);
|
||||
if (dateRange.from) {
|
||||
const fromDate = new Date(dateRange.from);
|
||||
if (itemDate < fromDate) matchesDateRange = false;
|
||||
}
|
||||
if (dateRange.to) {
|
||||
const toDate = new Date(dateRange.to);
|
||||
if (itemDate > toDate) matchesDateRange = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
matchesDescription &&
|
||||
matchesArea &&
|
||||
matchesStatus &&
|
||||
matchesPaymentStatus &&
|
||||
matchesSupplier &&
|
||||
matchesDateRange
|
||||
);
|
||||
});
|
||||
|
||||
const uniqueAreas = [
|
||||
...new Set(payments.map((item) => item.area).filter(Boolean)),
|
||||
].sort();
|
||||
const uniqueStatuses = [
|
||||
...new Set(payments.map((item) => item.status).filter(Boolean)),
|
||||
].sort();
|
||||
const uniquePaymentStatuses = [
|
||||
...new Set(payments.map((item) => item.paymentStatus).filter(Boolean)),
|
||||
].sort();
|
||||
const uniqueSuppliers = [
|
||||
...new Set(payments.map((item) => item.supplier).filter(Boolean)),
|
||||
].sort();
|
||||
|
||||
const clearFilters = () => {
|
||||
setDescriptionSearch("");
|
||||
setAreaFilter("");
|
||||
setStatusFilter("");
|
||||
setPaymentStatusFilter("");
|
||||
setSupplierFilter("");
|
||||
const currentMonth = getCurrentMonthRange();
|
||||
setDateRange({ from: currentMonth.start, to: currentMonth.end });
|
||||
};
|
||||
|
||||
const refreshMonthlyData = async () => {
|
||||
try {
|
||||
await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/payment/refreshmonthly`,
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error refreshing monthly data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
await refreshMonthlyData();
|
||||
|
||||
async function fetchMonthlyPaymentsData() {
|
||||
await fetch(
|
||||
import.meta.env.VITE_API_BASE_URL + "/expenses/monthlypayments"
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
const adaptedData = data.data.map((item) => ({
|
||||
id: item.id_expense,
|
||||
description: item.expense_description,
|
||||
date: new Date(item.payment_deadline).toISOString().split("T")[0],
|
||||
area: item.area.toUpperCase(),
|
||||
recurrence: item.recurrence_name,
|
||||
supplier: item.supplier,
|
||||
status: capitalizeFirstLetter(item.status),
|
||||
amount: `$${parseFloat(item.total || 0).toLocaleString()}`,
|
||||
paymentStatus: item.status_payment.toUpperCase(),
|
||||
more: {
|
||||
id: item.id_expense,
|
||||
description: item.expense_description,
|
||||
status: item.status_payment,
|
||||
},
|
||||
}));
|
||||
|
||||
const groupedData = adaptedData.sort((a, b) => {
|
||||
const descCompare = a.description.localeCompare(b.description);
|
||||
if (descCompare !== 0) return descCompare;
|
||||
|
||||
const areaCompare = a.area.localeCompare(b.area);
|
||||
if (areaCompare !== 0) return areaCompare;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
setPayments(groupedData);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching latest expenses:", error);
|
||||
});
|
||||
}
|
||||
|
||||
fetchMonthlyPaymentsData();
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: lang === "en" ? "EXPENSE DESCRIPTION" : "DESCRIPCIÓN DEL GASTO",
|
||||
key: "description",
|
||||
},
|
||||
{
|
||||
header: lang === "en" ? "PAYMENT DEADLINE" : "FECHA LÍMITE DE PAGO",
|
||||
key: "date",
|
||||
},
|
||||
{ header: lang === "en" ? "AREA" : "ÁREA", key: "area" },
|
||||
{ header: lang === "en" ? "RECURRENCE" : "RECURRENCIA", key: "recurrence" },
|
||||
{ header: lang === "en" ? "SUPPLIER" : "PROVEEDOR", key: "supplier" },
|
||||
{ header: lang === "en" ? "STATUS" : "ESTADO", key: "status" },
|
||||
{ header: lang === "en" ? "AMOUNT" : "IMPORTE", key: "amount" },
|
||||
{
|
||||
header: lang === "en" ? "PAYMENT STATUS" : "ESTADO DE PAGO",
|
||||
key: "paymentStatus",
|
||||
headerStyle: { textAlign: "center" },
|
||||
render: (status) => (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<button className={`status-button ${status.toLowerCase()}`}>
|
||||
{status}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function capitalizeFirstLetter(string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="monthly-report-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
{lang === "en" ? "Monthly Report" : "Reporte Mensual"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="filters-section">
|
||||
<div className="filters-row">
|
||||
<input
|
||||
type="text"
|
||||
value={descriptionSearch}
|
||||
onChange={(e) => setDescriptionSearch(e.target.value)}
|
||||
placeholder={
|
||||
lang === "en"
|
||||
? "Search by description..."
|
||||
: "Buscar por descripción..."
|
||||
}
|
||||
className="filter-search"
|
||||
/>
|
||||
|
||||
<div className="date-input-group">
|
||||
<label className="date-label">
|
||||
{lang === "es" ? "Desde:" : "From:"}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.from}
|
||||
onChange={(e) =>
|
||||
setDateRange({ ...dateRange, from: e.target.value })
|
||||
}
|
||||
className="date-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="date-input-group">
|
||||
<label className="date-label">
|
||||
{lang === "es" ? "Hasta:" : "To:"}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.to}
|
||||
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
className="date-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filters-row second-row">
|
||||
<select
|
||||
value={areaFilter}
|
||||
onChange={(e) => setAreaFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">
|
||||
{lang === "en" ? "All Areas" : "Todas las Áreas"}
|
||||
</option>
|
||||
{uniqueAreas.map((area, index) => (
|
||||
<option key={index} value={area}>
|
||||
{area}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">
|
||||
{lang === "en" ? "All Statuses" : "Todos los Estados"}
|
||||
</option>
|
||||
{uniqueStatuses.map((status, index) => (
|
||||
<option key={index} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={paymentStatusFilter}
|
||||
onChange={(e) => setPaymentStatusFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">
|
||||
{lang === "en"
|
||||
? "All Payment Statuses"
|
||||
: "Todos los Estados de Pago"}
|
||||
</option>
|
||||
{uniquePaymentStatuses.map((status, index) => (
|
||||
<option key={index} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={supplierFilter}
|
||||
onChange={(e) => setSupplierFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">
|
||||
{lang === "en" ? "All Suppliers" : "Todos los Proveedores"}
|
||||
</option>
|
||||
{uniqueSuppliers.map((supplier, index) => (
|
||||
<option key={index} value={supplier}>
|
||||
{supplier}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button onClick={clearFilters} className="clear-filters-btn">
|
||||
{lang === "en" ? "Clear Filters" : "Limpiar Filtros"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-section">
|
||||
<Table columns={columns} data={filteredData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
frontend/Frontend-Hotel/src/pages/Expenses/NewExpense.css
Normal file
254
frontend/Frontend-Hotel/src/pages/Expenses/NewExpense.css
Normal file
@@ -0,0 +1,254 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.new-expense-container {
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.new-expense-container * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-id {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background-color: #6c757d;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.back-button:active {
|
||||
background-color: #545b62;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-grid > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-grid label {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-grid input,
|
||||
.form-grid select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background-color: #f9f9f9;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-grid input:focus,
|
||||
.form-grid select:focus {
|
||||
outline: none;
|
||||
border-color: #5D1A2A;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.form-grid input:disabled,
|
||||
.form-grid select:disabled {
|
||||
background-color: #e9ecef;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.form-grid input[type="text"],
|
||||
.form-grid input[type="number"],
|
||||
.form-grid input[type="date"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: textfield;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.form-grid input[type="number"]::-webkit-outer-spin-button,
|
||||
.form-grid input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checkbox-field {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.checkbox-field label {
|
||||
margin-bottom: 0 !important;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.checkbox-field input[type="checkbox"] {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
min-width: 14px;
|
||||
min-height: 14px;
|
||||
cursor: pointer;
|
||||
accent-color: #5D1A2A;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.save-button-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background-color: #ffc107;
|
||||
color: #000;
|
||||
padding: 10px 30px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background-color: #f4b400;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.save-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.save-button:disabled {
|
||||
background-color: #e0e0e0;
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.product-table-section {
|
||||
margin-top: 25px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.product-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.product-table th, .product-table td {
|
||||
border: 1px solid #d4af37;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.product-table th {
|
||||
background-color: #800020;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.add-product-row {
|
||||
cursor: pointer;
|
||||
color: #d4af37;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tax-pill {
|
||||
background-color: white;
|
||||
border: 1px solid #d4af37;
|
||||
border-radius: 12px;
|
||||
padding: 2px 6px;
|
||||
color: #800020;
|
||||
}
|
||||
|
||||
.product-table tfoot td {
|
||||
border-top: 2px solid #d4af37;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.add-products {
|
||||
width: 100%;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.add-products h3 {
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.checkbox-field {
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
798
frontend/Frontend-Hotel/src/pages/Expenses/NewExpense.jsx
Normal file
798
frontend/Frontend-Hotel/src/pages/Expenses/NewExpense.jsx
Normal file
@@ -0,0 +1,798 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./NewExpense.css";
|
||||
import { useContext } from "react";
|
||||
import { langContext } from "../../context/LenguageContext";
|
||||
import { AuthContext } from "../../context/AuthContext";
|
||||
|
||||
export default function NewExpense() {
|
||||
const { lang } = useContext(langContext);
|
||||
const { userData } = useContext(AuthContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isEditMode = false;
|
||||
|
||||
const categoryRequiresProducts = (categoryId) => {
|
||||
return categoryId === 1 || categoryId === 10;
|
||||
};
|
||||
const [form, setForm] = useState({
|
||||
new_description: "",
|
||||
suppliers_id: "",
|
||||
new_request_date: "",
|
||||
new_payment_deadline: "",
|
||||
area: "",
|
||||
expense_cat: "",
|
||||
currency_id: "",
|
||||
new_tax: 1,
|
||||
subtotal: 0,
|
||||
iva: 0,
|
||||
ieps: 0,
|
||||
total: 0,
|
||||
needtoapprove: false,
|
||||
products: [],
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// estados para datos de selects
|
||||
const [suppliers, setSuppliers] = useState([]);
|
||||
const [areas, setAreas] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [currencies, setCurrencies] = useState([]);
|
||||
const [taxes, setTaxes] = useState([]);
|
||||
const [listproducts, setListProducts] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSelectData() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
import.meta.env.VITE_API_BASE_URL + "/expenses/getinfo",
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`Error fetching info: ${res.status}`);
|
||||
const data = await res.json();
|
||||
// Supongo que data tiene algo como:
|
||||
// { suppliers: [...], areas: [...], categories: [...], currencies: [...] }
|
||||
setSuppliers(data.suppliers || []);
|
||||
setAreas(data.areas || []);
|
||||
setCategories(data.categories || []);
|
||||
setCurrencies(data.currencies || []);
|
||||
setTaxes(data.tax || []);
|
||||
} catch (err) {
|
||||
console.error("Error cargando metadata (getinfo):", err);
|
||||
}
|
||||
}
|
||||
// Helper para cargar opciones de selects
|
||||
const fetchOptions = (url, setter, mapFn) => {
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + url)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const items =
|
||||
data.data ||
|
||||
data.currency ||
|
||||
data.categoryex ||
|
||||
data.approve ||
|
||||
data.request;
|
||||
if (items) {
|
||||
setter(items.map(mapFn));
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error(`Error fetching ${url}`, err));
|
||||
};
|
||||
fetchOptions("/products", (data) => {
|
||||
const sortedProducts = data.map(c => ({
|
||||
id: c.id_product,
|
||||
name: c.name_product,
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
setListProducts(sortedProducts);
|
||||
}, (c) => c);
|
||||
fetchSelectData();
|
||||
}, []);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, type, checked, value } = e.target;
|
||||
|
||||
// Crear una copia del formulario
|
||||
const updatedForm = {
|
||||
...form,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
};
|
||||
// Inicializar valores por si están vacíos
|
||||
const subtotal = parseFloat(updatedForm.subtotal) || 0;
|
||||
const ieps = parseFloat(updatedForm.ieps) || 0;
|
||||
|
||||
let ivaAmount = 0;
|
||||
|
||||
if (name === "total" && !categoryRequiresProducts(Number(updatedForm.expense_cat))) {
|
||||
const totalValue = parseFloat(value) || 0;
|
||||
const iepsValue = parseFloat(updatedForm.ieps) || 0;
|
||||
const taxRate = parseFloat(
|
||||
taxes.find((t) => t.id === parseInt(updatedForm.new_tax))?.number || 0
|
||||
);
|
||||
|
||||
if (taxRate > 0) {
|
||||
const subtotalBeforeIeps = totalValue - iepsValue;
|
||||
const calculatedSubtotal = subtotalBeforeIeps / (1 + taxRate);
|
||||
const calculatedIva = calculatedSubtotal * taxRate;
|
||||
|
||||
updatedForm.subtotal = calculatedSubtotal;
|
||||
updatedForm.iva = calculatedIva;
|
||||
updatedForm.total = totalValue;
|
||||
} else {
|
||||
updatedForm.subtotal = totalValue - iepsValue;
|
||||
updatedForm.iva = 0;
|
||||
updatedForm.total = totalValue;
|
||||
}
|
||||
|
||||
setForm({
|
||||
...updatedForm,
|
||||
subtotal: updatedForm.subtotal.toFixed(2),
|
||||
iva: updatedForm.iva.toFixed(2),
|
||||
total: updatedForm.total,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Si hay productos, recalcular desde ellos
|
||||
if (updatedForm.products.length > 0) {
|
||||
updatedForm.subtotal = updatedForm.products.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.subtotal) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
ivaAmount = updatedForm.products.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.iva) || 0),
|
||||
0
|
||||
);
|
||||
console.log(ivaAmount);
|
||||
} else {
|
||||
const taxRate = parseFloat(
|
||||
taxes.find((t) => t.id === parseInt(updatedForm.new_tax))?.number || 0
|
||||
);
|
||||
console.log(taxRate);
|
||||
ivaAmount = subtotal * taxRate;
|
||||
}
|
||||
|
||||
updatedForm.iva = ivaAmount;
|
||||
|
||||
// Calcular total final (subtotal + IVA + IEPS)
|
||||
updatedForm.total = subtotal + ivaAmount + ieps;
|
||||
|
||||
// Actualizar estado
|
||||
setForm({
|
||||
...updatedForm,
|
||||
iva: updatedForm.iva.toFixed(2),
|
||||
total: name === "total" ? updatedForm.total : updatedForm.total.toFixed(2),
|
||||
});
|
||||
};
|
||||
|
||||
const handleCategoryChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
const categoryValue = Number(value) || 0;
|
||||
setForm((prev) => {
|
||||
const updatedForm = { ...prev, [name]: categoryValue };
|
||||
if (categoryRequiresProducts(categoryValue)) {
|
||||
updatedForm.products = [
|
||||
{
|
||||
id_product: null,
|
||||
quantity: 1,
|
||||
unit_cost: 0,
|
||||
id_tax: 1,
|
||||
subtotal: 0,
|
||||
iva: 0,
|
||||
},
|
||||
];
|
||||
updatedForm.new_tax = "";
|
||||
} else {
|
||||
// Si cambia a otra categoría, vaciamos los productos
|
||||
updatedForm.products = [];
|
||||
}
|
||||
return updatedForm;
|
||||
});
|
||||
};
|
||||
|
||||
const handleProductChange = (index, field, value) => {
|
||||
const updatedProducts = [...form.products]; // Convertir a entero si es un campo numérico esperado
|
||||
const ieps = parseFloat(form.ieps) || 0;
|
||||
const processedValue = ["unit_cost"].includes(field)
|
||||
? parseFloat(value)
|
||||
: ["quantity", "id_tax", "id_product"].includes(field)
|
||||
? parseInt(value)
|
||||
: value;
|
||||
updatedProducts[index][field] = processedValue;
|
||||
|
||||
if (field === "quantity" || field === "unit_cost" || field === "id_tax") {
|
||||
const qty = parseFloat(updatedProducts[index].quantity) || 0;
|
||||
const cost = parseFloat(updatedProducts[index].unit_cost) || 0;
|
||||
const taxValue = parseFloat(
|
||||
taxes.find((t) => t.id === parseInt(updatedProducts[index].id_tax))
|
||||
?.number || 0
|
||||
);
|
||||
updatedProducts[index].subtotal = qty * cost;
|
||||
updatedProducts[index].iva = qty * cost * taxValue;
|
||||
}
|
||||
|
||||
// Recalcular el amount (suma de todos los productos)
|
||||
const subtotalAmount = updatedProducts.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.subtotal) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Recalcular el amount (suma de todos los productos)
|
||||
const ivaAmount = updatedProducts.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.iva) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
products: updatedProducts,
|
||||
subtotal: subtotalAmount.toFixed(2), // redondeado a 2 decimales
|
||||
iva: ivaAmount.toFixed(2), // redondeado a 2 decimales
|
||||
total: (subtotalAmount + ivaAmount + ieps).toFixed(2), // Calcular total final (subtotal + IVA + IEPS)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddProduct = () => {
|
||||
const ieps = parseFloat(form.ieps) || 0;
|
||||
|
||||
// Primero, agregamos el nuevo producto
|
||||
const updatedProducts = [
|
||||
...form.products,
|
||||
{
|
||||
id_product: null,
|
||||
quantity: 1,
|
||||
unit_cost: 0,
|
||||
id_tax: "",
|
||||
subtotal: 0,
|
||||
iva: 0,
|
||||
},
|
||||
];
|
||||
|
||||
// Ahora sí, recalculamos después de agregarlo
|
||||
const subtotalAmount = updatedProducts.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.subtotal) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const ivaAmount = updatedProducts.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.iva) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
products: updatedProducts,
|
||||
subtotal: subtotalAmount.toFixed(2),
|
||||
iva: ivaAmount.toFixed(2),
|
||||
total: (subtotalAmount + ivaAmount + ieps).toFixed(2),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRemoveProduct = (index) => {
|
||||
setForm((prev) => {
|
||||
const updated = [...prev.products];
|
||||
updated.splice(index, 1);
|
||||
|
||||
// Recalcular el amount (suma de todos los productos)
|
||||
const subtotalAmount = updated.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.subtotal) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Recalcular el amount (suma de todos los productos)
|
||||
const ivaAmount = updated.reduce(
|
||||
(acc, p) => acc + (parseFloat(p.iva) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const ieps = parseFloat(prev.ieps) || 0;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
products: updated,
|
||||
subtotal: subtotalAmount.toFixed(2), // redondeado a 2 decimales
|
||||
iva: ivaAmount.toFixed(2), // redondeado a 2 decimales
|
||||
total: (subtotalAmount + ivaAmount + ieps).toFixed(2), // Calcular total final (subtotal + IVA + IEPS)
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setSuccess(false);
|
||||
setError("");
|
||||
|
||||
// ✅ Validar campos obligatorios antes de enviar
|
||||
if (
|
||||
!form.suppliers_id ||
|
||||
!form.area ||
|
||||
!form.expense_cat ||
|
||||
!form.currency_id ||
|
||||
!form.total
|
||||
) {
|
||||
setError(
|
||||
lang === "en"
|
||||
? "Please fill in all required fields (Supplier, Area, Category, Currency, total)."
|
||||
: "Por favor llena todos los campos obligatorios (Proveedor, Área, Categoría, Moneda, Monto)."
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
new_description: form.new_description,
|
||||
suppliers_id: Number(form.suppliers_id),
|
||||
new_request_date: form.new_request_date,
|
||||
new_payment_deadline: form.new_payment_deadline,
|
||||
request_by: Number(userData.user_id),
|
||||
area: Number(form.area),
|
||||
expense_cat: Number(form.expense_cat),
|
||||
currency_id: Number(form.currency_id),
|
||||
needtoapprove: form.needtoapprove,
|
||||
products: form.products,
|
||||
new_subtotal: Number(form.subtotal),
|
||||
new_iva: Number(form.iva),
|
||||
new_ieps: Number(form.ieps),
|
||||
new_total: Number(form.total),
|
||||
};
|
||||
|
||||
// 🔍 LOGS para depurar el error 500
|
||||
console.group("🧾 Envío de nuevo gasto");
|
||||
console.log("Payload completo que se enviará al backend:", payload);
|
||||
console.log("Tipo de cada campo:");
|
||||
Object.entries(payload).forEach(([k, v]) =>
|
||||
console.log(`${k}:`, v, typeof v)
|
||||
);
|
||||
console.groupEnd();
|
||||
|
||||
// Enviar petición
|
||||
console.log('payload', payload);
|
||||
const res = await fetch(
|
||||
import.meta.env.VITE_API_BASE_URL + "/expenses/newexpense",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
console.log('res', res);
|
||||
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
|
||||
// 🧩 Nuevo: Log para inspeccionar respuesta cruda
|
||||
console.group("📨 Respuesta del servidor");
|
||||
console.log("Status:", res.status);
|
||||
console.log("Texto recibido:", text);
|
||||
console.groupEnd();
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("Error response:", res.status, data);
|
||||
throw new Error(`Server error ${res.status}: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
// limpiar formulario
|
||||
setForm({
|
||||
new_description: "",
|
||||
suppliers_id: "",
|
||||
new_request_date: "",
|
||||
new_payment_deadline: "",
|
||||
area: "",
|
||||
expense_cat: "",
|
||||
currency_id: "",
|
||||
subtotal: 0,
|
||||
needtoapprove: false,
|
||||
iva: 0,
|
||||
ieps: 0,
|
||||
total: 0,
|
||||
products: [],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("❌ handleSubmit error:", err);
|
||||
setError(err.message || "Unexpected error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="new-expense-container">
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<div className="form-id">{lang === "en" ? "New expense" : "Nuevo gasto"}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="back-button"
|
||||
onClick={() => navigate("/app/report-expense")}
|
||||
>
|
||||
<span>←</span>
|
||||
{lang === "en" ? "Back" : "Volver"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
<label>{lang === "en" ? "Description" : "Descripción"}</label>
|
||||
<input
|
||||
name="new_description"
|
||||
value={form.new_description}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Supplier" : "Proveedor"}</label>
|
||||
<select
|
||||
name="suppliers_id"
|
||||
value={form.suppliers_id}
|
||||
onChange={handleChange}
|
||||
required
|
||||
>
|
||||
<option value="">
|
||||
{lang === "en" ? "Select supplier" : "Seleccionar proveedor"}
|
||||
</option>
|
||||
{suppliers
|
||||
.sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase();
|
||||
const nameB = (b.name || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>
|
||||
{lang === "en" ? "Request date" : "Fecha de solicitud"}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="new_request_date"
|
||||
value={form.new_request_date}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Area" : "Área"}</label>
|
||||
<select
|
||||
name="area"
|
||||
value={form.area}
|
||||
onChange={handleChange}
|
||||
required
|
||||
>
|
||||
<option value="">
|
||||
{lang === "en" ? "Select area" : "Seleccionar área"}
|
||||
</option>
|
||||
{areas
|
||||
.sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase();
|
||||
const nameB = (b.name || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Category" : "Categoría"}</label>
|
||||
<select
|
||||
name="expense_cat"
|
||||
value={form.expense_cat}
|
||||
onChange={handleCategoryChange}
|
||||
required
|
||||
>
|
||||
<option value="">
|
||||
{lang === "en" ? "Select category" : "Seleccionar categoría"}
|
||||
</option>
|
||||
{categories
|
||||
.sort((a, b) => {
|
||||
const nameA = (lang === "en" ? (a.name || '') : (a.spanish_name || a.name || '')).toLowerCase();
|
||||
const nameB = (lang === "en" ? (b.name || '') : (b.spanish_name || b.name || '')).toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map((c) => {
|
||||
const displayName = lang === "en" ? (c.name || '') : (c.spanish_name || c.name || '');
|
||||
return (
|
||||
<option key={c.id} value={c.id}>
|
||||
{displayName}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>
|
||||
{lang === "en" ? "Payment deadline" : "Fecha límite de pago"}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="new_payment_deadline"
|
||||
value={form.new_payment_deadline}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Currency" : "Moneda"}</label>
|
||||
<select
|
||||
name="currency_id"
|
||||
value={form.currency_id}
|
||||
onChange={handleChange}
|
||||
required
|
||||
>
|
||||
<option value="">
|
||||
{lang === "en" ? "Select currency" : "Seleccionar moneda"}
|
||||
</option>
|
||||
{currencies.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{"Subtotal"}</label>
|
||||
<input
|
||||
name="subtotal"
|
||||
value={form.subtotal}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
required
|
||||
disabled={categoryRequiresProducts(Number(form.expense_cat))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!categoryRequiresProducts(Number(form.expense_cat)) && (
|
||||
<div>
|
||||
<label>{lang === "en" ? "VAT rate:" : "Tasa de IVA:"}</label>
|
||||
<select
|
||||
name="new_tax"
|
||||
value={form.new_tax}
|
||||
onChange={handleChange}
|
||||
disabled={categoryRequiresProducts(Number(form.expense_cat))}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{lang === "en" ? "Select Tax" : "Seleccione Impuesto"}
|
||||
</option>
|
||||
{taxes.map((tax) => (
|
||||
<option key={tax.id} value={tax.id}>
|
||||
{tax.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "VAT" : "IVA"}</label>
|
||||
<input
|
||||
name="iva"
|
||||
value={form.iva}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
required
|
||||
disabled={categoryRequiresProducts(Number(form.expense_cat))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{"IEPS"}</label>
|
||||
<input
|
||||
name="ieps"
|
||||
value={form.ieps}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{"Total"}</label>
|
||||
<input
|
||||
name="total"
|
||||
value={form.total}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
required
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Need to Approve (Campo solicitado en la estructura) */}
|
||||
<div className="checkbox-field">
|
||||
<label>{lang === "en" ? "Needs Approval:" : "Requiere aprobación:"}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="needtoapprove"
|
||||
checked={form.needtoapprove}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sección de Productos */}
|
||||
{categoryRequiresProducts(Number(form.expense_cat)) && (
|
||||
<div className="add-products">
|
||||
<h3 style={{ marginTop: "20px" }}>
|
||||
{lang === "en" ? "Products" : "Productos"}
|
||||
</h3>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #ccc",
|
||||
padding: "10px",
|
||||
marginTop: "10px",
|
||||
}}
|
||||
>
|
||||
{form.products.map((product, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
marginBottom: "10px",
|
||||
borderBottom: "1px dotted #eee",
|
||||
paddingBottom: "5px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label>
|
||||
{lang === "en" ? "Product ID:" : "ID Producto:"}
|
||||
</label>
|
||||
<select
|
||||
name="product_id"
|
||||
value={product.id_product || ""}
|
||||
onChange={(e) =>
|
||||
handleProductChange(
|
||||
index,
|
||||
"id_product",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{lang === "en"
|
||||
? "Select Product ID"
|
||||
: "Seleccione ID Producto"}
|
||||
</option>
|
||||
{listproducts.map((lp) => (
|
||||
<option key={lp.id} value={lp.id}>
|
||||
{lp.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "en" ? "Quantity:" : "Cantidad:"}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={product.quantity}
|
||||
onChange={(e) =>
|
||||
handleProductChange(index, "quantity", e.target.value)
|
||||
}
|
||||
placeholder={lang === "en" ? "Quantity" : "Cantidad"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
{lang === "en" ? "Unit Cost:" : "Precio Unitario:"}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={product.unit_cost}
|
||||
onChange={(e) =>
|
||||
handleProductChange(
|
||||
index,
|
||||
"unit_cost",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder={
|
||||
lang === "en" ? "Unit Cost" : "Precio Unitario"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "en" ? "VAT:" : "IVA:"}</label>
|
||||
<select
|
||||
name="product_id_tax"
|
||||
value={product.id_tax}
|
||||
onChange={(e) =>
|
||||
handleProductChange(index, "id_tax", e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{lang === "en" ? "Select Tax" : "Seleccione Impuesto"}
|
||||
</option>
|
||||
{taxes.map((tax) => (
|
||||
<option key={tax.id} value={tax.id}>
|
||||
{tax.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "end", gap: "10px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveProduct(index)}
|
||||
className="save-button"
|
||||
disabled={form.products.length <= 1}
|
||||
>
|
||||
{lang === "en" ? "Remove" : "Eliminar"}
|
||||
</button>
|
||||
{index === form.products.length - 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddProduct}
|
||||
className="save-button"
|
||||
>
|
||||
{lang === "en" ? "Add Product" : "Agregar producto"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="save-button-wrapper">
|
||||
<button type="submit" className="save-button" disabled={loading}>
|
||||
{loading
|
||||
? lang === "en"
|
||||
? "Saving..."
|
||||
: "Guardando..."
|
||||
: lang === "en"
|
||||
? "Save"
|
||||
: "Guardar"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<span style={{ color: "green" }}>
|
||||
{lang === "en"
|
||||
? "Expense saved successfully!"
|
||||
: "¡Gasto guardado con éxito!"}
|
||||
</span>
|
||||
)}
|
||||
{error && <span style={{ color: "red" }}>{error}</span>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
frontend/Frontend-Hotel/src/pages/Expenses/NewMonthlyPayment.css
Normal file
149
frontend/Frontend-Hotel/src/pages/Expenses/NewMonthlyPayment.css
Normal file
@@ -0,0 +1,149 @@
|
||||
|
||||
.new-expense-container {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-id {
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-grid label {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-grid input,
|
||||
.form-grid select {
|
||||
width: 100%;
|
||||
padding: 6px 4px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.save-button-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background-color: #ffcb05;
|
||||
color: #fff;
|
||||
padding: 10px 30px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background-color: #f4b400;
|
||||
}
|
||||
|
||||
.product-table-section {
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
|
||||
/*Actualización de New Expense con tabla*/
|
||||
|
||||
/* src/pages/Expenses/NewExpense.css */
|
||||
|
||||
.new-expense-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.save-button-wrapper {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background-color: #ffc107; /* amarillo/dorado */
|
||||
color: #000; /* ajustar según contraste */
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.product-table-section {
|
||||
margin-top: 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.product-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.product-table th, .product-table td {
|
||||
border: 1px solid #d4af37; /* dorado */
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.product-table th {
|
||||
background-color: #800020; /* bordo/morado oscuro, según tus imágenes */
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.add-product-row {
|
||||
cursor: pointer;
|
||||
color: #d4af37; /* dorado */
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tax-pill {
|
||||
background-color: white;
|
||||
border: 1px solid #d4af37;
|
||||
border-radius: 12px;
|
||||
padding: 2px 6px;
|
||||
color: #800020;
|
||||
}
|
||||
|
||||
.product-table tfoot td {
|
||||
border-top: 2px solid #d4af37;
|
||||
padding: 8px;
|
||||
}
|
||||
300
frontend/Frontend-Hotel/src/pages/Expenses/NewMonthlyPayment.jsx
Normal file
300
frontend/Frontend-Hotel/src/pages/Expenses/NewMonthlyPayment.jsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import './NewMonthlyPayment.css';
|
||||
// import '../Inventory/NewProduct.css ';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
|
||||
|
||||
//**////** */ */ REVISAR POR QUE NO AGREGA UN NEWMONTLYEXPENSE
|
||||
export default function NewMonthlyPayment() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [suppliers, setSuppliers] = useState([]);
|
||||
const [recurrences, setRecurrences] = useState([]);
|
||||
const [currencies, setCurrencies] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [areas, setAreas] = useState([]);
|
||||
const [taxes, setTaxes] = useState([]);
|
||||
const [form, setForm] = useState({
|
||||
descriptionex: '',
|
||||
recurrence_id: "",
|
||||
payment_type: "",
|
||||
currency_id: "",
|
||||
suppliers_id: '',
|
||||
area: '',
|
||||
expense_category: '',
|
||||
day_expense: '',
|
||||
tax_id: '',
|
||||
new_subtotal: ''
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (form.payment_type === "2") {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
new_subtotal: '',
|
||||
tax_id: ''
|
||||
}));
|
||||
}
|
||||
}, [form.payment_type]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/expenses/getinfo')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setSuppliers(data.suppliers);
|
||||
setCurrencies(data.currencies);
|
||||
setAreas(data.areas);
|
||||
setCategories(data.categories);
|
||||
setTaxes(data.tax);
|
||||
})
|
||||
.catch(err => console.error('Error fetching suppliers', err));
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/settings/recurrence')
|
||||
.then(res => res.json())
|
||||
.then(data => setRecurrences(data.currency));
|
||||
|
||||
}, []);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setForm(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const isVariable = Number(form.payment_type) === 2;
|
||||
|
||||
const payload = {
|
||||
...form,
|
||||
recurrence_id: Number(form.recurrence_id),
|
||||
payment_type: Number(form.payment_type),
|
||||
currency_id: Number(form.currency_id),
|
||||
area: Number(form.area),
|
||||
expense_category: Number(form.expense_category),
|
||||
day_expense: Number(form.day_expense),
|
||||
tax_id: isVariable ? null : form.tax_id,
|
||||
new_subtotal: isVariable ? null : form.new_subtotal
|
||||
};
|
||||
|
||||
const res = await fetch(import.meta.env.VITE_API_BASE_URL + '/payment/newexpmonthly', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Error al guardar el gasto mensual');
|
||||
|
||||
setSuccess(true);
|
||||
setForm({
|
||||
descriptionex: '',
|
||||
recurrence_id: '',
|
||||
payment_type: '',
|
||||
currency_id: '',
|
||||
suppliers_id: '',
|
||||
area: '',
|
||||
expense_category: '',
|
||||
day_expense: '',
|
||||
amount_expense: '',
|
||||
tax_id: '',
|
||||
new_subtotal: ''
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="new-expense-container">
|
||||
<div className="form-card">
|
||||
<div className="form-id">
|
||||
{lang === "en" ? "New monthly payment" : "Nuevo pago mensual"}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
<label>{lang === "en" ? "Description" : "Descripción"}</label>
|
||||
<input
|
||||
name="descriptionex"
|
||||
value={form.descriptionex}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Recurrence" : "Recurrencia"}</label>
|
||||
<select name="recurrence_id" value={form.recurrence_id} onChange={handleChange}>
|
||||
<option value="">{lang === "en" ? "Select a recurrence" : "Selecciona un período"}</option>
|
||||
{
|
||||
[...recurrences].sort((a, b) => a.name_recurrence.localeCompare(b.name_recurrence)).map(r => (
|
||||
<option key={r.id_recurrence} value={r.id_recurrence}>
|
||||
{r.name_recurrence}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Currency" : "Moneda"}</label>
|
||||
<select name="currency_id" value={form.currency_id} onChange={handleChange}>
|
||||
<option key="" value="">{lang === "en" ? "Select a currency" : "Selecciona una denominacion"}</option>
|
||||
{currencies.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Payment type" : "Tipo de pago"}</label>
|
||||
<select name="payment_type" value={form.payment_type} onChange={handleChange} required>
|
||||
<option value="">{lang === "en" ? "Select payment type" : "Selecciona tipo de pago"}</option>
|
||||
<option value={1}>{lang === "en" ? "Fixed" : "Fijo"}</option>
|
||||
<option value={2}>{lang === "en" ? "Variable" : "Variable"}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{form.payment_type !== "2" && (
|
||||
<div>
|
||||
<label>{lang === "en" ? "Amount" : "Importe"}</label>
|
||||
<input
|
||||
name="new_subtotal"
|
||||
value={form.new_subtotal}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
required={form.payment_type === "1"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Supplier" : "Proveedor"}</label>
|
||||
<select
|
||||
name="suppliers_id"
|
||||
value={form.suppliers_id}
|
||||
onChange={handleChange}
|
||||
required
|
||||
>
|
||||
<option value="">
|
||||
{lang === "en" ? "Select supplier" : "Seleccionar proveedor"}
|
||||
</option>
|
||||
{suppliers.map(s => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Area" : "Área"}</label>
|
||||
<select name="area" value={form.area} onChange={handleChange} required>
|
||||
<option value="">
|
||||
{lang === "en" ? "Select area" : "Seleccionar área"}
|
||||
</option>
|
||||
{
|
||||
[...areas].sort((a, b) => a.name.localeCompare(b.name)).map(a => (
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Category" : "Categoría"}</label>
|
||||
<select
|
||||
name="expense_category"
|
||||
value={form.expense_category}
|
||||
onChange={handleChange}
|
||||
required
|
||||
>
|
||||
<option value="">
|
||||
{lang === "en" ? "Select category" : "Seleccionar categoría"}
|
||||
</option>
|
||||
{
|
||||
categories
|
||||
.sort((a, b) => {
|
||||
const nameA = lang === "en" ? a.name : a.spanish_name;
|
||||
const nameB = lang === "en" ? b.name : b.spanish_name;
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{lang === "en" ? cat.name : cat.spanish_name}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{form.payment_type !== "2" && (
|
||||
<div>
|
||||
<label>{lang === "en" ? "Tax" : "Impuesto"}</label>
|
||||
<select
|
||||
name="tax_id"
|
||||
value={form.tax_id}
|
||||
onChange={handleChange}
|
||||
required={form.payment_type === "1"}
|
||||
>
|
||||
<option value="">
|
||||
{lang === "en" ? "Select tax" : "Seleccionar impuesto"}
|
||||
</option>
|
||||
{
|
||||
taxes.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Payment deadline" : "Fecha límite de pago"}</label>
|
||||
<input
|
||||
name="day_expense"
|
||||
value={form.day_expense}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="save-button-wrapper">
|
||||
<button type="submit" className="save-button" disabled={loading}>
|
||||
{loading
|
||||
? lang === "en" ? "Saving..." : "Guardando..."
|
||||
: lang === "en" ? "Save" : "Guardar"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<span style={{ color: 'green' }}>
|
||||
{lang === "en"
|
||||
? "Monthly payment saved!"
|
||||
: "¡Pago mensual guardado!"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{error && <span style={{ color: 'red' }}>{error}</span>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
63
frontend/Frontend-Hotel/src/pages/Expenses/NewSuppliers.css
Normal file
63
frontend/Frontend-Hotel/src/pages/Expenses/NewSuppliers.css
Normal file
@@ -0,0 +1,63 @@
|
||||
.new-supplier-container {
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-id {
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-grid label {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-grid input {
|
||||
width: 100%;
|
||||
padding: 6px 4px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.save-button-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background-color: #ffcb05;
|
||||
color: #fff;
|
||||
padding: 10px 30px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background-color: #f4b400;
|
||||
}
|
||||
212
frontend/Frontend-Hotel/src/pages/Expenses/NewSuppliers.jsx
Normal file
212
frontend/Frontend-Hotel/src/pages/Expenses/NewSuppliers.jsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { useState, useContext } from 'react';
|
||||
import './NewSuppliers.css';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export default function NewSuppliers() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [suppliers, setSuppliers] = useState([]);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
//Generador de IDs automáticos tipo SUP-0001
|
||||
const generateSupplierId = (index) => {
|
||||
const num = (index + 1).toString().padStart(4, '0');
|
||||
return `SUP-${num}`;
|
||||
};
|
||||
|
||||
//Esquema Yup (solo RFC y Name son obligatorios)
|
||||
const supplierSchema = Yup.object({
|
||||
new_rfc_suppliers: Yup.string()
|
||||
.required(lang === 'en' ? 'RFC is required' : 'El RFC es obligatorio')
|
||||
.min(12, lang === 'en' ? 'Min 12 characters' : 'Mínimo 12 caracteres')
|
||||
.max(13, lang === 'en' ? 'Max 13 characters' : 'Máximo 13 caracteres'),
|
||||
|
||||
new_name_suppliers: Yup.string()
|
||||
.required(lang === 'en' ? 'Name is required' : 'El nombre es obligatorio')
|
||||
.min(10, lang === 'en' ? 'Min 10 characters' : 'Mínimo 10 caracteres')
|
||||
.max(50, lang === 'en' ? 'Max 50 characters' : 'Máximo 50 caracteres'),
|
||||
|
||||
new_email_suppliers: Yup.string()
|
||||
.email(lang === 'en' ? 'Invalid email format' : 'Formato de correo no válido')
|
||||
.nullable()
|
||||
.transform((value, originalValue) => originalValue.trim() === '' ? null : value),
|
||||
|
||||
new_phone_suppliers: Yup.string()
|
||||
.max(15, lang === 'en' ? 'Max 15 digits' : 'Máximo 15 dígitos')
|
||||
.matches(/^[0-9+\- ]*$/, lang === 'en' ? 'Only numbers and symbols (+, -)' : 'Solo números y símbolos (+, -)')
|
||||
.nullable()
|
||||
.transform((value, originalValue) => originalValue.trim() === '' ? null : value)
|
||||
});
|
||||
|
||||
//Inicializar react-hook-form con Yup
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting }
|
||||
} = useForm({
|
||||
resolver: yupResolver(supplierSchema),
|
||||
defaultValues: {
|
||||
new_rfc_suppliers: '',
|
||||
new_name_suppliers: '',
|
||||
new_email_suppliers: '',
|
||||
new_phone_suppliers: ''
|
||||
}
|
||||
});
|
||||
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
setSuccess(false);
|
||||
setErrorMsg('');
|
||||
|
||||
const body = {
|
||||
new_name_supp: data.new_name_suppliers,
|
||||
new_rfc_supp: data.new_rfc_suppliers,
|
||||
new_mail_supp: data.new_email_suppliers,
|
||||
new_phone_supp: data.new_phone_suppliers
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/products/newsupplier`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
//Si el backend indica conflicto o duplicado
|
||||
if (!response.ok) {
|
||||
if (response.status === 409 || result.message?.toLowerCase().includes('exists')) {
|
||||
setErrorMsg(
|
||||
lang === 'en'
|
||||
? 'This supplier is already registered.'
|
||||
: 'Este proveedor ya está registrado.'
|
||||
);
|
||||
} else {
|
||||
setErrorMsg(
|
||||
lang === 'en'
|
||||
? 'Error saving supplier.'
|
||||
: 'Error al guardar el proveedor.'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Supplier creado:', result);
|
||||
setSuppliers(prev => [...prev, result]);
|
||||
setSuccess(true);
|
||||
reset();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error al crear supplier:', error);
|
||||
setErrorMsg(
|
||||
lang === 'en'
|
||||
? 'Network error while saving supplier.'
|
||||
: 'Error de red al guardar el proveedor.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Actualizar estado de pago (PUT)
|
||||
const updatePaymentStatus = async (supplierId) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/status/paymentupdate/${supplierId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ status: 1 })
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error('Error al actualizar status');
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Payment status updated:', result);
|
||||
} catch (error) {
|
||||
console.error('Error actualizando el status de pago:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="new-supplier-container">
|
||||
<div className="form-card">
|
||||
<h2>{lang === 'en' ? 'New Supplier' : 'Nuevo Proveedor'}</h2>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="form-grid">
|
||||
{/* RFC */}
|
||||
<div>
|
||||
<label>RFC*</label>
|
||||
<input type="text" {...register('new_rfc_suppliers')} />
|
||||
{errors.new_rfc_suppliers && (
|
||||
<p className="error-text">{errors.new_rfc_suppliers.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label>{lang === 'en' ? 'Name*' : 'Nombre*'}</label>
|
||||
<input type="text" {...register('new_name_suppliers')} />
|
||||
{errors.new_name_suppliers && (
|
||||
<p className="error-text">{errors.new_name_suppliers.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label>Email</label>
|
||||
<input type="email" {...register('new_email_suppliers')} />
|
||||
{errors.new_email_suppliers && (
|
||||
<p className="error-text">{errors.new_email_suppliers.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label>{lang === 'en' ? 'Phone' : 'Teléfono'}</label>
|
||||
<input type="text" {...register('new_phone_suppliers')} />
|
||||
{errors.new_phone_suppliers && (
|
||||
<p className="error-text">{errors.new_phone_suppliers.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<p style={{ color: 'red', fontWeight: 'bold' }}>
|
||||
{errorMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
{success && (
|
||||
<p style={{ color: 'green', fontWeight: 'bold' }}>
|
||||
{lang === 'en'
|
||||
? 'Supplier saved successfully!'
|
||||
: '¡Proveedor guardado exitosamente!'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="save-button-wrapper">
|
||||
<button type="submit" className="save-button" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? (lang === 'en' ? 'Saving...' : 'Guardando...')
|
||||
: (lang === 'en' ? 'Save Supplier' : 'Guardar Proveedor')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
255
frontend/Frontend-Hotel/src/pages/Expenses/Payments.css
Normal file
255
frontend/Frontend-Hotel/src/pages/Expenses/Payments.css
Normal file
@@ -0,0 +1,255 @@
|
||||
.report-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filters-row.second-row {
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.filters-row.second-row .date-filter-group {
|
||||
flex: 0 0 auto;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.filters-row.second-row .clear-filters-btn {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.filters-row .filter-search {
|
||||
flex: 1;
|
||||
max-width: 280px;
|
||||
min-width: 180px;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background-color: #fff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filters-row .filter-select {
|
||||
min-width: 180px;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 30px;
|
||||
font-size: 14px;
|
||||
background-color: #fff;
|
||||
margin: 0;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.filter-group select,
|
||||
.filter-group input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.filter-group select:focus,
|
||||
.filter-group input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
align-self: end;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.clear-filters-btn:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-filters {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filters-row.second-row {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filters-row .filter-search {
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
order: -1;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background-color: #fff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
min-width: 100%;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background-color: #fff;
|
||||
margin: 0;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.date-filter-group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.date-inputs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.summary-card div {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.active:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
.status-badge.reject {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.reject:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.status-button {
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-button.approved {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-button.pending {
|
||||
background-color: #ffc107;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.status-button.rejected {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
333
frontend/Frontend-Hotel/src/pages/Expenses/Payments.jsx
Normal file
333
frontend/Frontend-Hotel/src/pages/Expenses/Payments.jsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Table from '../../components/Table/HotelTable';
|
||||
import SummaryCard from '../../components/SummaryCard';
|
||||
import '../../components/Filters/Filters.css';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
import ConfirmationModal from '../../components/Modals/ConfirmationModal';
|
||||
import axios from 'axios';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ExcelExportButton from '../../components/ExcelExportButton';
|
||||
import './Payments.css';
|
||||
|
||||
export default function Payments() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [data, setData] = useState([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedStatus, setSelectedStatus] = useState('');
|
||||
const [selectedStatusId, setSelectedStatusId] = useState(null);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
status: '',
|
||||
requestedBy: '',
|
||||
requestDateFrom: '',
|
||||
requestDateTo: '',
|
||||
approvedDateFrom: '',
|
||||
approvedDateTo: '',
|
||||
searchTerm: ''
|
||||
});
|
||||
const loadExpenses = () => {
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/expenses/reportexpenses')
|
||||
.then(res => res.json())
|
||||
.then(resData => {
|
||||
const mapped = resData.data.map(item => {
|
||||
return {
|
||||
id: item.id_expense,
|
||||
description: item.expense_description,
|
||||
requestDate: item.request_date?.split('T')[0],
|
||||
approvedDate: item.approval_date?.split('T')[0],
|
||||
requestedBy: item.requested_by,
|
||||
totalpesos: Number(item.totalpesos),
|
||||
status: item.status_approval,
|
||||
paymentStatus: item.status_payment,
|
||||
supplier: item.supplier || 'Unknown',
|
||||
is_monthly : item.is_monthly
|
||||
};
|
||||
});
|
||||
setData(mapped.filter(c => c.is_monthly !== true && c.status === "Approved" && c.paymentStatus === "PENDING"));
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadExpenses();
|
||||
}, []);
|
||||
|
||||
const handleOpenModal = (id, statusType, statusId) => {
|
||||
setSelectedId(id);
|
||||
setSelectedStatus(statusType);
|
||||
setSelectedStatusId(statusId);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedId || !selectedStatus || !selectedStatusId) return;
|
||||
|
||||
const statusValue = selectedStatusId;
|
||||
|
||||
axios.put(`${import.meta.env.VITE_API_BASE_URL}/status/paymentupdate/${selectedId}`, {
|
||||
status: statusValue
|
||||
})
|
||||
.then(() => {
|
||||
loadExpenses();
|
||||
|
||||
setModalOpen(false);
|
||||
setSelectedId(null);
|
||||
setSelectedStatus(null);
|
||||
})
|
||||
.catch(() => {
|
||||
alert('❌ Error al actualizar el estado. Intenta nuevamente.');
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const filteredData = data.filter((item) => {
|
||||
if (filters.searchTerm && (!item.description || !item.description.toLowerCase().includes(filters.searchTerm.toLowerCase()))) return false;
|
||||
|
||||
if (filters.status && item.status !== filters.status) return false;
|
||||
|
||||
if (filters.requestedBy && item.requestedBy !== filters.requestedBy) return false;
|
||||
|
||||
if (filters.requestDateFrom || filters.requestDateTo) {
|
||||
if (!item.requestDate) return false;
|
||||
|
||||
const requestDate = new Date(item.requestDate);
|
||||
if (filters.requestDateFrom) {
|
||||
const from = new Date(filters.requestDateFrom);
|
||||
if (requestDate < from) return false;
|
||||
}
|
||||
if (filters.requestDateTo) {
|
||||
const to = new Date(filters.requestDateTo);
|
||||
if (requestDate > to) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.approvedDateFrom || filters.approvedDateTo) {
|
||||
if (!item.approvedDate) return false;
|
||||
|
||||
const approvedDate = new Date(item.approvedDate);
|
||||
if (filters.approvedDateFrom) {
|
||||
const from = new Date(filters.approvedDateFrom);
|
||||
if (approvedDate < from) return false;
|
||||
}
|
||||
if (filters.approvedDateTo) {
|
||||
const to = new Date(filters.approvedDateTo);
|
||||
if (approvedDate > to) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalAmount = filteredData
|
||||
.reduce((acc, curr) => {
|
||||
return curr.totalpesos + acc;
|
||||
}, 0);
|
||||
|
||||
const uniqueStatuses = [...new Set(data.map(item => item.status))].filter(Boolean);
|
||||
const uniqueRequestedBy = [...new Set(data.map(item => item.requestedBy))].filter(Boolean);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: lang === "en" ? "EXPENSE DESCRIPTION" : "DESCRIPCIÓN DEL GASTO",
|
||||
key: 'description',
|
||||
render: (text, row) => (
|
||||
<Link to={`/app/expenses/${row.id}`} style={{ color: 'blue', textDecoration: 'underline' }}>
|
||||
{text}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ header: lang === "en" ? "REQUEST DATE" : "FECHA DE SOLICITUD", key: 'requestDate' },
|
||||
{ header: lang === "en" ? "APPROVED DATE" : "FECHA DE APROBACIÓN", key: 'approvedDate' },
|
||||
{ header: lang === "en" ? "REQUESTED BY" : "SOLICITADO POR", key: 'requestedBy' },
|
||||
{
|
||||
header: lang === "en" ? "AMOUNT" : "IMPORTE",
|
||||
key: 'totalpesos',
|
||||
render: (value) => {
|
||||
const num = parseFloat(value || 0);
|
||||
return `$${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
header: lang === "en" ? "APPROVAL STATUS" : "ESTADO DE APROBACIÓN",
|
||||
key: 'status',
|
||||
headerStyle: { textAlign: 'center' },
|
||||
render: (status) => (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<button
|
||||
className={`status-button ${status.toLowerCase()}`}
|
||||
disabled
|
||||
style={{
|
||||
cursor: 'default',
|
||||
opacity: 1,
|
||||
}}
|
||||
>
|
||||
{status.toUpperCase()}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: lang === "en" ? "PAYMENT STATUS" : "ESTADO DE PAGO",
|
||||
key: 'id',
|
||||
headerStyle: { textAlign: 'center' },
|
||||
render: (id) => {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'center' }}>
|
||||
<button
|
||||
className="status-badge active"
|
||||
onClick={() => handleOpenModal(id, lang === "en" ? "PAID" : "PAGADO", 1)}
|
||||
>
|
||||
{lang === "en" ? "PAID" : "PAGAR"}
|
||||
</button>
|
||||
<button
|
||||
className="status-badge reject"
|
||||
onClick={() => handleOpenModal(id, lang === "en" ? "REJECTED" : "RECHAZADO", 3)}
|
||||
>
|
||||
{lang === "en" ? "REJECTED" : "RECHAZAR"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const exportColumns = columns.slice(0, -1);
|
||||
|
||||
|
||||
return (
|
||||
<div className="report-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
{lang === "en" ? "Payment" : "Pagos"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="page-filters">
|
||||
<div className="filters-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={lang === "en" ? "Search by description..." : "Buscar por descripción..."}
|
||||
value={filters.searchTerm}
|
||||
onChange={(e) => setFilters({ ...filters, searchTerm: e.target.value })}
|
||||
className="filter-search"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "en" ? "All Approval Statuses" : "Todos los estados de aprobación"}</option>
|
||||
{uniqueStatuses.map((status, index) => (
|
||||
<option key={index} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.requestedBy}
|
||||
onChange={(e) => setFilters({ ...filters, requestedBy: e.target.value })}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "en" ? "All Requesters" : "Todos los solicitantes"}</option>
|
||||
{uniqueRequestedBy.map((user, index) => (
|
||||
<option key={index} value={user}>
|
||||
{user}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filters-row second-row">
|
||||
<div className="date-filter-group">
|
||||
<label className="date-filter-label">
|
||||
{lang === "en" ? "REQUEST DATE" : "FECHA DE SOLICITUD"}
|
||||
</label>
|
||||
<div className="date-inputs">
|
||||
<input
|
||||
type="date"
|
||||
value={filters.requestDateFrom}
|
||||
onChange={(e) => setFilters({ ...filters, requestDateFrom: e.target.value })}
|
||||
className="date-input"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.requestDateTo}
|
||||
onChange={(e) => setFilters({ ...filters, requestDateTo: e.target.value })}
|
||||
className="date-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="date-filter-group">
|
||||
<label className="date-filter-label">
|
||||
{lang === "en" ? "APPROVED DATE" : "FECHA DE APROBACIÓN"}
|
||||
</label>
|
||||
<div className="date-inputs">
|
||||
<input
|
||||
type="date"
|
||||
value={filters.approvedDateFrom}
|
||||
onChange={(e) => setFilters({ ...filters, approvedDateFrom: e.target.value })}
|
||||
className="date-input"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.approvedDateTo}
|
||||
onChange={(e) => setFilters({ ...filters, approvedDateTo: e.target.value })}
|
||||
className="date-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setFilters({
|
||||
status: '',
|
||||
requestedBy: '',
|
||||
requestDateFrom: '',
|
||||
requestDateTo: '',
|
||||
approvedDateFrom: '',
|
||||
approvedDateTo: '',
|
||||
searchTerm: ''
|
||||
})}
|
||||
className="clear-filters-btn"
|
||||
>
|
||||
{lang === "en" ? "Clear Filters" : "Limpiar Filtros"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="summary-actions-section">
|
||||
<div className="summary-cards-wrapper">
|
||||
<SummaryCard
|
||||
title={lang === "en" ? "Total Spent" : "Total Gastado"}
|
||||
amount={totalAmount}
|
||||
/>
|
||||
</div>
|
||||
<ExcelExportButton
|
||||
data={filteredData}
|
||||
columns={exportColumns}
|
||||
filenamePrefix={lang === 'es' ? 'pagos' : 'payments'}
|
||||
sheetName={lang === 'es' ? 'Pagos' : 'Payments'}
|
||||
className="export-button"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="table-section">
|
||||
<Table columns={columns} data={filteredData} />
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={modalOpen}
|
||||
statusType={selectedStatus}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
frontend/Frontend-Hotel/src/pages/Expenses/PurchaseEntries.jsx
Normal file
156
frontend/Frontend-Hotel/src/pages/Expenses/PurchaseEntries.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ConfirmationModal from '../../components/Modals/ConfirmationModal';
|
||||
import Table from '../../components/Table/HotelTable';
|
||||
import { Link } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
//**////** */ */ REVISAR DELIVERED NULL
|
||||
export default function PurchaseEntries() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [purchases, setPurchases] = useState([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [checking, setChecking] = useState(null);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
|
||||
const loadPurchases = () => {
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/purchases/getpurchases')
|
||||
.then(res => res.json())
|
||||
.then(resData => {
|
||||
const mapped = resData.data.map(item => ({
|
||||
id: item.id_purchase_dt,
|
||||
expense_id: item.id_expense,
|
||||
name: item.product_name,
|
||||
quantity: item.quantity,
|
||||
delivered: item.delivered,
|
||||
check: 0,
|
||||
}));
|
||||
setPurchases(mapped);
|
||||
})
|
||||
.catch(err => console.error('Error loading expense report:', err));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadPurchases();
|
||||
}, []);
|
||||
|
||||
const filteredPurchases = purchases.filter(purchase =>
|
||||
purchase.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{ header: lang === "en" ? "PURCHASE ID" : "ID DE COMPRA", key: 'id' },
|
||||
{ header: lang === "en" ? "EXPENSE ID" : "ID DE GASTO", key: 'expense_id' },
|
||||
{ header: lang === "en" ? "PRODUCT" : "PRODUCTO", key: 'name' },
|
||||
{ header: lang === "en" ? "REQUESTED" : "SOLICITADOS", key: 'quantity' },
|
||||
{ header: lang === "en" ? "DELIVERED" : "ENTREGADOS", key: 'delivered' },
|
||||
{
|
||||
header: lang === "en" ? "CHECKING" : "RECIBIENDO",
|
||||
key: 'check',
|
||||
render: (check, row) => (
|
||||
<input
|
||||
type="number"
|
||||
value={check} // opcional, para mantener sincronía
|
||||
onChange={(e) => handleCheckingChange(row.id, e.target.value)}
|
||||
placeholder={lang === "en" ? "Checking" : "Recibiendo"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: lang === "en" ? "CONFIRM RECEIVED" : "CONFIRMAR RECEPCIÓN",
|
||||
key: 'id',
|
||||
headerStyle: { textAlign: 'center' },
|
||||
render: (id, row) => (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<button
|
||||
className='status-button'
|
||||
onClick={() => handleOpenModal(id, row.check)}
|
||||
>
|
||||
{lang === "en" ? "CONFIRM" : "CONFIRMAR"}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleCheckingChange = (id, value) => {
|
||||
setPurchases(prev =>
|
||||
prev.map(p =>
|
||||
p.id === id ? { ...p, check: value } : p
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const handleOpenModal = (id, check) => {
|
||||
setSelectedId(id);
|
||||
setChecking(check);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedId || !checking) return;
|
||||
|
||||
axios.put(`${import.meta.env.VITE_API_BASE_URL}/purchases/entry/${selectedId}`, {
|
||||
checking: parseInt(checking)
|
||||
})
|
||||
.then((res) => {
|
||||
console.log(res.data.message || 'Estado actualizado correctamente');
|
||||
|
||||
//Actualizar purchases
|
||||
loadPurchases();
|
||||
|
||||
// Cerrar el modal y limpiar selección
|
||||
setModalOpen(false);
|
||||
setSelectedId(null);
|
||||
setChecking(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Error al actualizar el estado del gasto ${selectedId}:`, err);
|
||||
alert('❌ Error al actualizar el estado. Intenta nuevamente.');
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="report-page">
|
||||
<h2>{lang === "en" ? "Purchase Entries" : "Entradas de compras"}</h2>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={lang === "en" ? "Search by product name..." : "Buscar por nombre del producto..."}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
border: 'none',
|
||||
borderRadius: '30px',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 0 0 2px #f4f4f4',
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif",
|
||||
fontWeight: 'bold',
|
||||
minWidth: '250px',
|
||||
width: '100%',
|
||||
maxWidth: '400px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table columns={columns} data={filteredPurchases} />
|
||||
<ConfirmationModal
|
||||
isOpen={modalOpen}
|
||||
statusType={checking}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
362
frontend/Frontend-Hotel/src/pages/Expenses/ReportExpense.css
Normal file
362
frontend/Frontend-Hotel/src/pages/Expenses/ReportExpense.css
Normal file
@@ -0,0 +1,362 @@
|
||||
.report-page {
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: #1a1a1a;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.report-page .filters-section {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.report-page .filters-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.report-page .filters-row.second-row {
|
||||
justify-content: flex-end;
|
||||
max-width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.report-page .filters-row.second-row .date-filter-group {
|
||||
flex: 0 0 auto;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.report-page .filters-row.second-row .filter-search {
|
||||
flex: 0 1 300px;
|
||||
max-width: 300px;
|
||||
min-width: 200px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.report-page .filters-row.second-row .clear-filters-btn {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
.report-page .filters-row.second-row {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-search {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.filter-search::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.filter-search:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.filter-search:hover {
|
||||
box-shadow: 0 0 0 2px #e6e6e6;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
min-width: 180px;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.filter-select:hover {
|
||||
box-shadow: 0 0 0 2px #e6e6e6;
|
||||
}
|
||||
|
||||
.date-filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-filter-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.date-inputs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
min-width: 145px;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.date-input:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.date-input:hover {
|
||||
box-shadow: 0 0 0 2px #e6e6e6;
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
padding: 10px 20px;
|
||||
background-color: white;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.clear-filters-btn:hover {
|
||||
background-color: #f5f5f5;
|
||||
border-color: #ccc;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.summary-actions-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary-cards-wrapper {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: linear-gradient(145deg, #ffffff, #f8f9fa);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
min-width: 180px;
|
||||
flex: 1;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.info-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.info-card .card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 0 12px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.info-card .card-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
|
||||
.table-section {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.report-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.report-page .filters-section {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.report-page .filters-row {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.report-page .filters-row.second-row {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.report-page .filter-search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 100%;
|
||||
order: -1;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.report-page .filter-select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.report-page .date-filter-group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.report-page .date-inputs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.report-page .date-input {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.report-page .clear-filters-btn {
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.summary-actions-section {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.summary-cards-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
404
frontend/Frontend-Hotel/src/pages/Expenses/ReportExpense.jsx
Normal file
404
frontend/Frontend-Hotel/src/pages/Expenses/ReportExpense.jsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Table from '../../components/Table/HotelTable';
|
||||
import SummaryCard from '../../components/SummaryCard';
|
||||
import './ReportExpense.css';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
import ExcelExportButton from '../../components/ExcelExportButton';
|
||||
|
||||
export default function ReportExpense() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [data, setData] = useState([]);
|
||||
|
||||
const refreshMonthlyData = async () => {
|
||||
try {
|
||||
await fetch(`${import.meta.env.VITE_API_BASE_URL}/payment/refreshmonthly`, {
|
||||
method: 'GET'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error refreshing monthly data from Expenses section:', error);
|
||||
}
|
||||
};
|
||||
const [approvedDateRange, setApprovedDateRange] = useState({ from: '', to: '' });
|
||||
const [requestDateRange, setRequestDateRange] = useState({ from: '', to: '' });
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [paymentStatusFilter, setPaymentStatusFilter] = useState('');
|
||||
const [requestedByFilter, setRequestedByFilter] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [areaFilter, setAreaFilter] = useState('');
|
||||
const [descriptionSearch, setDescriptionSearch] = useState('');
|
||||
|
||||
const clearFilters = () => {
|
||||
setDescriptionSearch('');
|
||||
setStatusFilter('');
|
||||
setPaymentStatusFilter('');
|
||||
setRequestedByFilter('');
|
||||
setCategoryFilter('');
|
||||
setAreaFilter('');
|
||||
setRequestDateRange({ from: '', to: '' });
|
||||
setApprovedDateRange({ from: '', to: '' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
await refreshMonthlyData();
|
||||
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/expenses/reportexpenses')
|
||||
.then(res => res.json())
|
||||
.then(resData => {
|
||||
const mapped = resData.data.map(item => ({
|
||||
id: item.id_expense,
|
||||
description: item.expense_description,
|
||||
requestDate: item.request_date?.split('T')[0],
|
||||
approvedDate: item.approval_date?.split('T')[0],
|
||||
requestedBy: item.requested_by,
|
||||
totalpesos: Number(item.totalpesos),
|
||||
status: item.status_approval,
|
||||
paymentStatus: item.status_payment,
|
||||
supplier: item.supplier || 'Unknown',
|
||||
category: item.category,
|
||||
area: item.area,
|
||||
is_monthly: item.is_monthly
|
||||
}));
|
||||
const filteredData = mapped.filter(c => c.is_monthly !== true);
|
||||
setData(filteredData);
|
||||
|
||||
})
|
||||
.catch(err => console.error('Error loading expense report:', err));
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const uniqueStatuses = [...new Set(data.map(item => item.status).filter(Boolean))].sort();
|
||||
const uniquePaymentStatuses = [...new Set(data.map(item => item.paymentStatus).filter(Boolean))].sort();
|
||||
const uniqueRequestedBy = [...new Set(data.map(item => item.requestedBy).filter(Boolean))].sort();
|
||||
const uniqueCategories = [...new Set(data.map(item => item.category).filter(Boolean))].sort();
|
||||
const uniqueAreas = [...new Set(data.map(item => item.area).filter(Boolean))].sort();
|
||||
|
||||
const filteredData = data.filter((item) => {
|
||||
if (requestDateRange.from || requestDateRange.to) {
|
||||
if (!item.requestDate) return false;
|
||||
|
||||
const request = new Date(item.requestDate);
|
||||
if (requestDateRange.from) {
|
||||
const from = new Date(requestDateRange.from);
|
||||
if (request < from) return false;
|
||||
}
|
||||
if (requestDateRange.to) {
|
||||
const to = new Date(requestDateRange.to);
|
||||
if (request > to) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (approvedDateRange.from || approvedDateRange.to) {
|
||||
if (!item.approvedDate) return false;
|
||||
|
||||
const approved = new Date(item.approvedDate);
|
||||
if (approvedDateRange.from) {
|
||||
const from = new Date(approvedDateRange.from);
|
||||
if (approved < from) return false;
|
||||
}
|
||||
if (approvedDateRange.to) {
|
||||
const to = new Date(approvedDateRange.to);
|
||||
if (approved > to) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (statusFilter && item.status !== statusFilter) return false;
|
||||
|
||||
if (paymentStatusFilter && item.paymentStatus !== paymentStatusFilter) return false;
|
||||
|
||||
if (requestedByFilter && item.requestedBy !== requestedByFilter) return false;
|
||||
|
||||
if (categoryFilter && item.category !== categoryFilter) return false;
|
||||
|
||||
if (areaFilter && item.area !== areaFilter) return false;
|
||||
|
||||
if (descriptionSearch && !item.description?.toLowerCase().includes(descriptionSearch.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalAmount = filteredData
|
||||
.filter(item => item.status === "Approved" && item.paymentStatus === "PAID")
|
||||
.reduce((acc, curr) => {
|
||||
return curr.totalpesos + acc;
|
||||
}, 0);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
return `${month}/${day}/${year}`;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: lang === "en" ? "EXPENSE DESCRIPTION" : "DESCRIPCIÓN DEL GASTO",
|
||||
key: 'description',
|
||||
render: (text, row) => {
|
||||
const targetUrl = row.is_monthly ? `/app/expenses/${row.id}` : `/app/expenses/edit/${row.id}`;
|
||||
return (
|
||||
<Link to={targetUrl} style={{ color: 'blue', textDecoration: 'underline' }}>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: lang === "en" ? "REQUEST DATE" : "FECHA DE SOLICITUD",
|
||||
key: 'requestDate',
|
||||
render: (value) => formatDate(value)
|
||||
},
|
||||
{
|
||||
header: lang === "en" ? "APPROVED DATE" : "FECHA DE APROBACIÓN",
|
||||
key: 'approvedDate',
|
||||
render: (value) => formatDate(value)
|
||||
},
|
||||
{ header: lang === "en" ? "REQUESTED BY" : "SOLICITADO POR", key: 'requestedBy' },
|
||||
{ header: lang === "en" ? "CATEGORY" : "CATEGORÍA", key: 'category' },
|
||||
{ header: lang === "en" ? "AREA" : "ÁREA", key: 'area' },
|
||||
{
|
||||
header: lang === "en" ? "AMOUNT MXN" : "IMPORTE",
|
||||
key: 'totalpesos',
|
||||
render: (value) => {
|
||||
const num = parseFloat(value || 0);
|
||||
return `$${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
header: lang === "en" ? "APPROVAL STATUS" : "ESTADO DE APROBACIÓN",
|
||||
key: 'status',
|
||||
headerStyle: { textAlign: 'center' },
|
||||
render: (status) => (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<button
|
||||
className={`status-button ${status.toLowerCase()}`}
|
||||
disabled
|
||||
style={{
|
||||
cursor: 'default',
|
||||
opacity: 1,
|
||||
}}
|
||||
>
|
||||
{status.toUpperCase()}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: lang === "en" ? "PAYMENT STATUS" : "ESTADO DE PAGO",
|
||||
key: 'paymentStatus',
|
||||
headerStyle: { textAlign: 'center' },
|
||||
render: (status) => {
|
||||
const statusClass = status.toLowerCase();
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'center' }}>
|
||||
<button
|
||||
className={`status-button ${statusClass}`}
|
||||
disabled
|
||||
style={{
|
||||
cursor: 'default',
|
||||
opacity: 1,
|
||||
}}
|
||||
>
|
||||
{status.toUpperCase()}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const exportColumns = [
|
||||
{ header: lang === "en" ? "EXPENSE DESCRIPTION" : "DESCRIPCIÓN DEL GASTO", key: 'description' },
|
||||
{ header: lang === "en" ? "REQUEST DATE" : "FECHA DE SOLICITUD", key: 'requestDate' },
|
||||
{ header: lang === "en" ? "APPROVED DATE" : "FECHA DE APROBACIÓN", key: 'approvedDate' },
|
||||
{ header: lang === "en" ? "REQUESTED BY" : "SOLICITADO POR", key: 'requestedBy' },
|
||||
{ header: lang === "en" ? "CATEGORY" : "CATEGORÍA", key: 'category' },
|
||||
{ header: lang === "en" ? "AREA" : "ÁREA", key: 'area' },
|
||||
{ header: lang === "en" ? "AMOUNT MXN" : "IMPORTE", key: 'totalpesos' },
|
||||
{ header: lang === "en" ? "APPROVAL STATUS" : "ESTADO DE APROBACIÓN", key: 'status' },
|
||||
{ header: lang === "en" ? "PAYMENT STATUS" : "ESTADO DE PAGO", key: 'paymentStatus' },
|
||||
];
|
||||
|
||||
|
||||
const pendingPaymentsCount = filteredData.filter(
|
||||
(item) => item.paymentStatus?.toLowerCase() === 'pending'
|
||||
).length;
|
||||
|
||||
const rejectedExpensesCount = filteredData.filter(
|
||||
(item) => item.status === "Rejected" && item.paymentStatus === "REJECTED"
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="report-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
{lang === "en" ? "Report" : "Reporte"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="filters-section">
|
||||
<div className="filters-row">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "en" ? "All Approval Statuses" : "Todos los estados de aprobación"}</option>
|
||||
{uniqueStatuses.map((status, index) => (
|
||||
<option key={index} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={paymentStatusFilter}
|
||||
onChange={(e) => setPaymentStatusFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "en" ? "All Payment Statuses" : "Todos los estados de pago"}</option>
|
||||
{uniquePaymentStatuses.map((status, index) => (
|
||||
<option key={index} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={requestedByFilter}
|
||||
onChange={(e) => setRequestedByFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "en" ? "All Requesters" : "Todos los solicitantes"}</option>
|
||||
{uniqueRequestedBy.map((requester, index) => (
|
||||
<option key={index} value={requester}>
|
||||
{requester}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "en" ? "All Categories" : "Todas las categorías"}</option>
|
||||
{uniqueCategories.map((category, index) => (
|
||||
<option key={index} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={areaFilter}
|
||||
onChange={(e) => setAreaFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "en" ? "All Areas" : "Todas las áreas"}</option>
|
||||
{uniqueAreas.map((area, index) => (
|
||||
<option key={index} value={area}>
|
||||
{area}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filters-row second-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={lang === "en" ? "Search by description..." : "Buscar por descripción..."}
|
||||
value={descriptionSearch}
|
||||
onChange={(e) => setDescriptionSearch(e.target.value)}
|
||||
className="filter-search"
|
||||
/>
|
||||
|
||||
<div className="date-filter-group">
|
||||
<label className="date-filter-label">
|
||||
{lang === "en" ? "REQUEST DATE" : "FECHA DE SOLICITUD"}
|
||||
</label>
|
||||
<div className="date-inputs">
|
||||
<input
|
||||
type="date"
|
||||
value={requestDateRange.from}
|
||||
onChange={(e) => setRequestDateRange({ ...requestDateRange, from: e.target.value })}
|
||||
className="date-input"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={requestDateRange.to}
|
||||
onChange={(e) => setRequestDateRange({ ...requestDateRange, to: e.target.value })}
|
||||
className="date-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="date-filter-group">
|
||||
<label className="date-filter-label">
|
||||
{lang === "en" ? "APPROVED DATE" : "FECHA DE APROBACIÓN"}
|
||||
</label>
|
||||
<div className="date-inputs">
|
||||
<input
|
||||
type="date"
|
||||
value={approvedDateRange.from}
|
||||
onChange={(e) => setApprovedDateRange({ ...approvedDateRange, from: e.target.value })}
|
||||
className="date-input"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={approvedDateRange.to}
|
||||
onChange={(e) => setApprovedDateRange({ ...approvedDateRange, to: e.target.value })}
|
||||
className="date-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="clear-filters-btn"
|
||||
>
|
||||
{lang === "en" ? "Clear Filters" : "Limpiar Filtros"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="summary-actions-section">
|
||||
<div className="summary-cards-wrapper">
|
||||
<SummaryCard
|
||||
title={lang === "en" ? "Total Spent" : "Total Gastado"}
|
||||
amount={parseFloat(totalAmount || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
/>
|
||||
<div className="info-card">
|
||||
<h3 className="card-title">
|
||||
{lang === "en" ? "Pending Approved Payments" : "Pagos aprobados pendientes"}
|
||||
</h3>
|
||||
<div className="card-value">{pendingPaymentsCount}</div>
|
||||
</div>
|
||||
<div className="info-card">
|
||||
<h3 className="card-title">
|
||||
{lang === "en" ? "Rejected Expenses" : "Gastos rechazados"}
|
||||
</h3>
|
||||
<div className="card-value">{rejectedExpensesCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ExcelExportButton
|
||||
data={filteredData}
|
||||
columns={exportColumns}
|
||||
filenamePrefix={lang === "en" ? "expense-report" : "reporte-gastos"}
|
||||
sheetName={lang === "en" ? "Expense Report" : "Reporte de Gastos"}
|
||||
className="export-button"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="table-section">
|
||||
<Table columns={columns} data={filteredData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
.approved-page {
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: #1a1a1a;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filters-section .date-range-filter {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-search {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.filter-search::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.filter-search:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.filter-search:hover {
|
||||
box-shadow: 0 0 0 2px #e6e6e6;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
min-width: 180px;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.filter-select:hover {
|
||||
box-shadow: 0 0 0 2px #e6e6e6;
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
padding: 10px 20px;
|
||||
background-color: white;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.clear-filters-btn:hover {
|
||||
background-color: #f5f5f5;
|
||||
border-color: #ccc;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.summary-cards-wrapper {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: linear-gradient(145deg, #ffffff, #f8f9fa);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
min-width: 180px;
|
||||
flex: 1;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.info-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.info-card .card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 0 12px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.info-card .card-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.approved-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-search {
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary-cards-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import DateRangeFilter from '../../components/Filters/DateRangeFilter';
|
||||
import SummaryCard from '../../components/SummaryCard';
|
||||
import '../../components/Filters/Filters.css';
|
||||
import './Approved.css';
|
||||
import Table from '../../components/Table/HotelTable';
|
||||
import axios from 'axios';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
//**////** */ */
|
||||
export default function Approved() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [approvedExpenses, setApprovedExpenses] = useState([]);
|
||||
const [filteredExpenses, setFilteredExpenses] = useState([]);
|
||||
const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
const [totalApprovedFromAPI, setTotalApprovedFromAPI] = useState(0);
|
||||
const [mainSupplier, setMainSupplier] = useState('N/A');
|
||||
const [descriptionFilter, setDescriptionFilter] = useState('');
|
||||
const [areaFilter, setAreaFilter] = useState('');
|
||||
|
||||
|
||||
|
||||
//Obtener lista de gastos aprobados
|
||||
useEffect(() => {
|
||||
axios.get(import.meta.env.VITE_API_BASE_URL + '/expenses/approvedexpenses')
|
||||
.then((res) => {
|
||||
const formatted = res.data.data.map((item, index) => ({
|
||||
id: index + 1,
|
||||
description: item.expense_description,
|
||||
requestDate: new Date(item.request_date).toISOString().split('T')[0],
|
||||
approvedDate: new Date(item.approval_date).toISOString().split('T')[0],
|
||||
area: item.area,
|
||||
requestedBy: item.requested_by,
|
||||
amount: parseFloat(item.amount),
|
||||
}));
|
||||
|
||||
setApprovedExpenses(formatted);
|
||||
setFilteredExpenses(formatted);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response) {
|
||||
console.error(`❌ Error ${err.response.status}: ${err.response.data.message}`);
|
||||
} else {
|
||||
console.error('❌ Error fetching approved expenses:', err.message);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
//Obtener total aprobado (GET con opción 1)
|
||||
useEffect(() => {
|
||||
axios.post(import.meta.env.VITE_API_BASE_URL + '/expenses/totalapproved', { option: 1 })
|
||||
.then((response) => {
|
||||
// Aquí asumo que response.data tiene la forma { message, data }
|
||||
// El valor está en response.data.data y es string, convertir a número:
|
||||
const total = parseFloat(response.data.data);
|
||||
setTotalApprovedFromAPI(isNaN(total) ? 0 : total);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching total approved:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
|
||||
// Obtener proveedor principal
|
||||
useEffect(() => {
|
||||
axios.get(import.meta.env.VITE_API_BASE_URL + '/expenses/mainsupplier')
|
||||
.then((res) => {
|
||||
const supplier = res.data?.data?.[0]?.supplier || 'N/A';
|
||||
setMainSupplier(supplier);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response) {
|
||||
console.error(`❌ Error ${err.response.status}: ${err.response.data.message}`);
|
||||
} else {
|
||||
console.error('❌ Error fetching main supplier:', err.message);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let filtered = [...approvedExpenses];
|
||||
|
||||
if (dateRange.from && dateRange.to) {
|
||||
const from = new Date(dateRange.from);
|
||||
const to = new Date(dateRange.to);
|
||||
filtered = filtered.filter((item) => {
|
||||
const itemDate = new Date(item.approvedDate);
|
||||
return itemDate >= from && itemDate <= to;
|
||||
});
|
||||
}
|
||||
|
||||
if (descriptionFilter) {
|
||||
filtered = filtered.filter((item) =>
|
||||
item.description?.toLowerCase().includes(descriptionFilter.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (areaFilter) {
|
||||
filtered = filtered.filter((item) => item.area === areaFilter);
|
||||
}
|
||||
|
||||
setFilteredExpenses(filtered);
|
||||
}, [dateRange, approvedExpenses, descriptionFilter, areaFilter]);
|
||||
|
||||
// Columnas para la tabla
|
||||
const columns = [
|
||||
{
|
||||
header: lang === "en" ? "EXPENSE DESCRIPTION" : "DESCRIPCIÓN DEL GASTO",
|
||||
key: 'description',
|
||||
render: (text, row) => (
|
||||
<Link
|
||||
to={`/app/expenses/${row.id}`}
|
||||
style={{ color: 'blue', textDecoration: 'underline' }}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
,
|
||||
{ header: lang === "en" ? "REQUEST DATE" : "FECHA DE SOLICITUD", key: 'requestDate' },
|
||||
{ header: lang === "en" ? "APPROVED DATE" : "FECHA DE APROBACIÓN", key: 'approvedDate' },
|
||||
{ header: lang === "en" ? "AREA" : "ÁREA", key: 'area' },
|
||||
{ header: lang === "en" ? "REQUESTED BY" : "SOLICITADO POR", key: 'requestedBy' },
|
||||
{
|
||||
header: lang === "en" ? "AMOUNT" : "IMPORTE",
|
||||
key: 'amount',
|
||||
render: (amount) => `$${amount.toLocaleString()}`
|
||||
},
|
||||
{
|
||||
header: lang === "en" ? "STATUS" : "ESTADO",
|
||||
key: 'id',
|
||||
headerStyle: { textAlign: 'center' },
|
||||
render: () => (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<button className="status-button approve" style={{ pointerEvents: 'none' }}>
|
||||
{lang === "en" ? "APPROVED" : "APROBADO"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const uniqueAreas = [...new Set(approvedExpenses.map(item => item.area).filter(Boolean))].sort();
|
||||
|
||||
const clearFilters = () => {
|
||||
setDateRange({ from: '', to: '' });
|
||||
setDescriptionFilter('');
|
||||
setAreaFilter('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="approved-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
{lang === "en" ? "Approved Expenses" : "Gastos Aprobados"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="filters-section">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={lang === "en" ? "Search by description..." : "Buscar por descripción..."}
|
||||
value={descriptionFilter}
|
||||
onChange={(e) => setDescriptionFilter(e.target.value)}
|
||||
className="filter-search"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={areaFilter}
|
||||
onChange={(e) => setAreaFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "en" ? "All Areas" : "Todas las Áreas"}</option>
|
||||
{uniqueAreas.map((area, index) => (
|
||||
<option key={index} value={area}>
|
||||
{area}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<DateRangeFilter
|
||||
dateRange={dateRange}
|
||||
onDateChange={setDateRange}
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="clear-filters-btn"
|
||||
>
|
||||
{lang === 'es' ? 'Limpiar filtros' : 'Clear filters'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="summary-cards-wrapper">
|
||||
<SummaryCard
|
||||
title={lang === "en" ? "Total Approved" : "Total Aprobado"}
|
||||
amount={totalApprovedFromAPI}
|
||||
/>
|
||||
<div className="info-card">
|
||||
<h3 className="card-title">{lang === "en" ? "Main Supplier" : "Proveedor Principal"}</h3>
|
||||
<div className="card-value">{mainSupplier}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-section">
|
||||
<Table columns={columns} data={filteredExpenses} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
.rejected-page {
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: #1a1a1a;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filters-section .date-range-filter {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-search {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.filter-search::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.filter-search:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.filter-search:hover {
|
||||
box-shadow: 0 0 0 2px #e6e6e6;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
min-width: 180px;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.filter-select:hover {
|
||||
box-shadow: 0 0 0 2px #e6e6e6;
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
padding: 10px 20px;
|
||||
background-color: white;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.clear-filters-btn:hover {
|
||||
background-color: #f5f5f5;
|
||||
border-color: #ccc;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.summary-cards-wrapper {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: linear-gradient(145deg, #ffffff, #f8f9fa);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
min-width: 180px;
|
||||
flex: 1;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.info-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.info-card .card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 0 12px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.info-card .card-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rejected-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-search {
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary-cards-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import DateRangeFilter from '../../components/Filters/DateRangeFilter';
|
||||
import SummaryCard from '../../components/SummaryCard';
|
||||
import '../../components/Filters/Filters.css';
|
||||
import './Rejected.css';
|
||||
import Table from '../../components/Table/HotelTable';
|
||||
import axios from 'axios';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
//**////** */ */ APROBADOS Y RECHAZADOS
|
||||
export default function Rejected() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [rejectedExpenses, setRejectedExpenses] = useState([]);
|
||||
const [filteredExpenses, setFilteredExpenses] = useState([]);
|
||||
const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
const [totalRejected, setTotalRejected] = useState(0);
|
||||
const [mainSupplier, setMainSupplier] = useState('N/A');
|
||||
const [descriptionFilter, setDescriptionFilter] = useState('');
|
||||
const [areaFilter, setAreaFilter] = useState('');
|
||||
|
||||
//Obtener lista de gastos rechazados
|
||||
useEffect(() => {
|
||||
axios.get(import.meta.env.VITE_API_BASE_URL + '/expenses/rejectedexpenses')
|
||||
.then((res) => {
|
||||
const formatted = res.data.data.map((item, index) => ({
|
||||
id: index + 1,
|
||||
description: item.expense_description,
|
||||
requestDate: new Date(item.request_date).toISOString().split('T')[0],
|
||||
rejectedDate: new Date(item.reject_date).toISOString().split('T')[0],
|
||||
area: item.area,
|
||||
requestedBy: item.requested_by,
|
||||
amount: parseFloat(item.amount),
|
||||
supplier: 'N/A'
|
||||
}));
|
||||
setRejectedExpenses(formatted);
|
||||
setFilteredExpenses(formatted);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('❌ Error fetching rejected expenses:', err.message);
|
||||
});
|
||||
}, []);
|
||||
|
||||
//Obtener Total Rejected usando método GET correcto
|
||||
useEffect(() => {
|
||||
axios.post(import.meta.env.VITE_API_BASE_URL + '/expenses/totalapproved', { option: 2 })
|
||||
.then((res) => {
|
||||
const total = parseFloat(res.data.data);
|
||||
setTotalRejected(isNaN(total) ? 0 : total);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response) {
|
||||
console.error(`❌ Error ${err.response.status}: ${err.response.data.message}`);
|
||||
} else {
|
||||
console.error('❌ Error fetching total rejected amount:', err.message);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
//Obtener proveedor principal
|
||||
useEffect(() => {
|
||||
axios.get(import.meta.env.VITE_API_BASE_URL + '/expenses/mainsupplier')
|
||||
.then((res) => {
|
||||
if (res.data.data.length > 0) {
|
||||
setMainSupplier(res.data.data[0].supplier);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response) {
|
||||
console.error(`❌ Error ${err.response.status}: ${err.response.data.message}`);
|
||||
} else {
|
||||
console.error('❌ Error fetching main supplier:', err.message);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let filtered = [...rejectedExpenses];
|
||||
|
||||
if (dateRange.from && dateRange.to) {
|
||||
const from = new Date(dateRange.from);
|
||||
const to = new Date(dateRange.to);
|
||||
filtered = filtered.filter((item) => {
|
||||
const itemDate = new Date(item.rejectedDate);
|
||||
return itemDate >= from && itemDate <= to;
|
||||
});
|
||||
}
|
||||
|
||||
if (descriptionFilter) {
|
||||
filtered = filtered.filter((item) =>
|
||||
item.description?.toLowerCase().includes(descriptionFilter.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (areaFilter) {
|
||||
filtered = filtered.filter((item) => item.area === areaFilter);
|
||||
}
|
||||
|
||||
setFilteredExpenses(filtered);
|
||||
}, [dateRange, rejectedExpenses, descriptionFilter, areaFilter]);
|
||||
|
||||
//Columnas de tabla
|
||||
const columns = [
|
||||
{
|
||||
header: lang === "en" ? "EXPENSE DESCRIPTION" : "DESCRIPCIÓN DEL GASTO",
|
||||
key: 'description',
|
||||
render: (text, row) => (
|
||||
<Link
|
||||
to={`/app/expenses/${row.id}`}
|
||||
style={{ color: 'blue', textDecoration: 'underline' }}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ header: lang === "en" ? "REQUEST DATE" : "FECHA DE SOLICITUD", key: 'requestDate' },
|
||||
{ header: lang === "en" ? "REJECTED DATE" : "FECHA DE RECHAZO", key: 'rejectedDate' },
|
||||
{ header: lang === "en" ? "AREA" : "ÁREA", key: 'area' },
|
||||
{ header: lang === "en" ? "REQUESTED BY" : "SOLICITADO POR", key: 'requestedBy' },
|
||||
{
|
||||
header: lang === "en" ? "AMOUNT" : "IMPORTE",
|
||||
key: 'amount',
|
||||
render: (amount) => `$${amount.toLocaleString()}`
|
||||
},
|
||||
{
|
||||
header: lang === "en" ? "STATUS" : "ESTADO",
|
||||
key: 'id',
|
||||
headerStyle: { textAlign: 'center' },
|
||||
render: () => (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<button className="status-button reject" style={{ pointerEvents: 'none' }}>
|
||||
{lang === "en" ? "REJECTED" : "RECHAZADO"}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const uniqueAreas = [...new Set(rejectedExpenses.map(item => item.area).filter(Boolean))].sort();
|
||||
|
||||
const clearFilters = () => {
|
||||
setDateRange({ from: '', to: '' });
|
||||
setDescriptionFilter('');
|
||||
setAreaFilter('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rejected-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
{lang === "en" ? "Rejected Expenses" : "Gastos Rechazados"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="filters-section">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={lang === "en" ? "Search by description..." : "Buscar por descripción..."}
|
||||
value={descriptionFilter}
|
||||
onChange={(e) => setDescriptionFilter(e.target.value)}
|
||||
className="filter-search"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={areaFilter}
|
||||
onChange={(e) => setAreaFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "en" ? "All Areas" : "Todas las Áreas"}</option>
|
||||
{uniqueAreas.map((area, index) => (
|
||||
<option key={index} value={area}>
|
||||
{area}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<DateRangeFilter
|
||||
dateRange={dateRange}
|
||||
onDateChange={setDateRange}
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="clear-filters-btn"
|
||||
>
|
||||
{lang === 'es' ? 'Limpiar filtros' : 'Clear filters'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="summary-cards-wrapper">
|
||||
<SummaryCard
|
||||
title={lang === "en" ? "Total Rejected" : "Total Rechazado"}
|
||||
amount={totalRejected}
|
||||
/>
|
||||
<div className="info-card">
|
||||
<h3 className="card-title">{lang === "en" ? "Main Supplier" : "Proveedor Principal"}</h3>
|
||||
<div className="card-value">{mainSupplier}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-section">
|
||||
<Table columns={columns} data={filteredExpenses} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// import React, { useEffect, useState } from 'react';
|
||||
// import '../../components/Filters/Filters.css';
|
||||
// import Table from '../../components/Table/HotelTable';
|
||||
// import axios from 'axios';
|
||||
|
||||
// export default function Rejected() {
|
||||
// const [rejectedExpenses, setRejectedExpenses] = useState([]);
|
||||
// const [filteredExpenses, setFilteredExpenses] = useState([]);
|
||||
// const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
// const [totalRejected, setTotalRejected] = useState(0);
|
||||
// const [mainSupplier, setMainSupplier] = useState('N/A');
|
||||
|
||||
// // ✅ Cargar lista de gastos rechazados
|
||||
// useEffect(() => {
|
||||
// axios.get(import.meta.env.VITE_API_BASE_URL + '/expenses/rejectedexpenses')
|
||||
// .then((res) => {
|
||||
// const formatted = res.data.data.map((item, index) => ({
|
||||
// id: index + 1,
|
||||
// description: item.expense_description,
|
||||
// requestDate: new Date(item.request_date).toISOString().split('T')[0],
|
||||
// rejectedDate: new Date(item.reject_date).toISOString().split('T')[0],
|
||||
// area: item.area,
|
||||
// requestedBy: item.requested_by,
|
||||
// amount: parseFloat(item.amount),
|
||||
// supplier: 'N/A'
|
||||
// }));
|
||||
// setRejectedExpenses(formatted);
|
||||
// setFilteredExpenses(formatted);
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// console.error('Error fetching rejected expenses:', err);
|
||||
// });
|
||||
// }, []);
|
||||
|
||||
// // ✅ CORREGIDO: Método GET con parámetro en URL
|
||||
// useEffect(() => {
|
||||
// axios.get(import.meta.env.VITE_API_BASE_URL + '/expenses/totalapproved/2')
|
||||
// .then((res) => {
|
||||
// setTotalRejected(parseFloat(res.data.data));
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// console.error('Error fetching total rejected amount:', err);
|
||||
// });
|
||||
// }, []);
|
||||
|
||||
|
||||
// // ✅ Obtener Main Supplier
|
||||
// useEffect(() => {
|
||||
// axios.get(import.meta.env.VITE_API_BASE_URL + '/expenses/mainsupplier')
|
||||
// .then((res) => {
|
||||
// if (res.data.data.length > 0) {
|
||||
// setMainSupplier(res.data.data[0].supplier); // "Costco"
|
||||
// }
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// console.error('Error fetching main supplier:', err);
|
||||
// });
|
||||
// }, []);
|
||||
|
||||
// // ✅ Filtrar por fechas
|
||||
// useEffect(() => {
|
||||
// if (dateRange.from && dateRange.to) {
|
||||
// const from = new Date(dateRange.from);
|
||||
// const to = new Date(dateRange.to);
|
||||
// const filtered = rejectedExpenses.filter((item) => {
|
||||
// const itemDate = new Date(item.rejectedDate);
|
||||
// return itemDate >= from && itemDate <= to;
|
||||
// });
|
||||
// setFilteredExpenses(filtered);
|
||||
// } else {
|
||||
// setFilteredExpenses(rejectedExpenses);
|
||||
// }
|
||||
// }, [dateRange, rejectedExpenses]);
|
||||
|
||||
// const columns = [
|
||||
// {
|
||||
// header: 'EXPENSE DESCRIPTION',
|
||||
// key: 'description',
|
||||
// render: (text) => <span>{text}</span>,
|
||||
// },
|
||||
// { header: 'REQUEST DATE', key: 'requestDate' },
|
||||
// { header: 'REJECTED DATE', key: 'rejectedDate' },
|
||||
// { header: 'AREA', key: 'area' },
|
||||
// { header: 'REQUESTED BY', key: 'requestedBy' },
|
||||
// {
|
||||
// header: 'AMOUNT',
|
||||
// key: 'amount',
|
||||
// render: (amount) => `$${amount.toLocaleString()}`
|
||||
// },
|
||||
// {
|
||||
// header: 'STATUS',
|
||||
// key: 'id',
|
||||
// render: () => (
|
||||
// <button className="status-button reject" style={{ pointerEvents: 'none' }}>
|
||||
// REJECTED
|
||||
// </button>
|
||||
// ),
|
||||
// },
|
||||
// ];
|
||||
|
||||
// return (
|
||||
// <div className="rejected-page">
|
||||
// <h2>REJECTED</h2>
|
||||
|
||||
// {/* Filtros de fecha */}
|
||||
// <div className="page-filters">
|
||||
// <input
|
||||
// type="date"
|
||||
// value={dateRange.from}
|
||||
// onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
|
||||
// />
|
||||
// <input
|
||||
// type="date"
|
||||
// value={dateRange.to}
|
||||
// onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
// />
|
||||
// </div>
|
||||
|
||||
// {/* Summary Cards */}
|
||||
// <div className="summary-container">
|
||||
// <div className="summary-card">
|
||||
// <strong>Total Rejected</strong>
|
||||
// <div>${totalRejected.toLocaleString()}</div>
|
||||
// </div>
|
||||
// <div className="summary-card">
|
||||
// <strong>Main Supplier</strong>
|
||||
// <div>{mainSupplier}</div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {/* Tabla */}
|
||||
// <Table columns={columns} data={filteredExpenses} />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
|
||||
// import React, { useEffect, useState } from 'react';
|
||||
// import '../../components/Filters/Filters.css';
|
||||
// import Table from '../../components/Table/HotelTable';
|
||||
// import axios from 'axios';
|
||||
|
||||
// export default function Rejected() {
|
||||
// const [rejectedExpenses, setRejectedExpenses] = useState([]);
|
||||
// const [filteredExpenses, setFilteredExpenses] = useState([]);
|
||||
// const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
|
||||
// //Obtener datos reales desde el backend
|
||||
// useEffect(() => {
|
||||
// axios.get(import.meta.env.VITE_API_BASE_URL + '/expenses/rejectedexpenses')
|
||||
// .then((res) => {
|
||||
// const formatted = res.data.data.map((item, index) => ({
|
||||
// id: index + 1, // temporal, ya que no viene `id_expense`
|
||||
// description: item.expense_description,
|
||||
// requestDate: new Date(item.request_date).toISOString().split('T')[0],
|
||||
// rejectedDate: new Date(item.reject_date).toISOString().split('T')[0],
|
||||
// area: item.area,
|
||||
// requestedBy: item.requested_by,
|
||||
// amount: parseFloat(item.amount),
|
||||
// supplier: 'N/A' // ❗ backend no envía proveedor
|
||||
// }));
|
||||
|
||||
// setRejectedExpenses(formatted);
|
||||
// setFilteredExpenses(formatted);
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// console.error('Error fetching rejected expenses:', err);
|
||||
// });
|
||||
// }, []);
|
||||
|
||||
// //Filtrar por fechas
|
||||
// useEffect(() => {
|
||||
// if (dateRange.from && dateRange.to) {
|
||||
// const from = new Date(dateRange.from);
|
||||
// const to = new Date(dateRange.to);
|
||||
// const filtered = rejectedExpenses.filter((item) => {
|
||||
// const itemDate = new Date(item.rejectedDate);
|
||||
// return itemDate >= from && itemDate <= to;
|
||||
// });
|
||||
// setFilteredExpenses(filtered);
|
||||
// } else {
|
||||
// setFilteredExpenses(rejectedExpenses);
|
||||
// }
|
||||
// }, [dateRange, rejectedExpenses]);
|
||||
|
||||
// //Columnas de la tabla
|
||||
// const columns = [
|
||||
// {
|
||||
// header: 'EXPENSE DESCRIPTION',
|
||||
// key: 'description',
|
||||
// render: (text) => <span>{text}</span>, // ❌ sin enlace por falta de ID
|
||||
// },
|
||||
// { header: 'REQUEST DATE', key: 'requestDate' },
|
||||
// { header: 'REJECTED DATE', key: 'rejectedDate' },
|
||||
// { header: 'AREA', key: 'area' },
|
||||
// { header: 'REQUESTED BY', key: 'requestedBy' },
|
||||
// {
|
||||
// header: 'AMOUNT',
|
||||
// key: 'amount',
|
||||
// render: (amount) => `$${amount.toLocaleString()}`
|
||||
// },
|
||||
// {
|
||||
// header: 'STATUS',
|
||||
// key: 'id',
|
||||
// render: () => (
|
||||
// <button className="status-button reject" style={{ pointerEvents: 'none' }}>
|
||||
// REJECTED
|
||||
// </button>
|
||||
// ),
|
||||
// },
|
||||
// ];
|
||||
|
||||
// //Cálculo de total rechazado
|
||||
// const totalAmount = filteredExpenses.reduce((sum, item) => sum + item.amount, 0);
|
||||
|
||||
// //Proveedor principal (no aplica)
|
||||
// const mainSupplier = 'N/A';
|
||||
|
||||
// return (
|
||||
// <div className="rejected-page">
|
||||
// <h2>REJECTED</h2>
|
||||
|
||||
// {/* Filtros de fecha */}
|
||||
// <div className="page-filters">
|
||||
// <input
|
||||
// type="date"
|
||||
// value={dateRange.from}
|
||||
// onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
|
||||
// />
|
||||
// <input
|
||||
// type="date"
|
||||
// value={dateRange.to}
|
||||
// onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
// />
|
||||
// </div>
|
||||
|
||||
// {/* Summary Cards */}
|
||||
// <div className="summary-container">
|
||||
// <div className="summary-card">
|
||||
// <strong>Total Rejected</strong>
|
||||
// <div>${totalAmount.toLocaleString()}</div>
|
||||
// </div>
|
||||
// <div className="summary-card">
|
||||
// <strong>Main Supplier</strong>
|
||||
// <div>{mainSupplier}</div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {/* Tabla */}
|
||||
// <Table columns={columns} data={filteredExpenses} />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
|
||||
130
frontend/Frontend-Hotel/src/pages/Hotel/Properties.jsx
Normal file
130
frontend/Frontend-Hotel/src/pages/Hotel/Properties.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useState } from 'react';
|
||||
import Table from '../../components/Table/HotelTable';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
|
||||
export default function PropertiesPage() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [properties, setProperties] = useState([
|
||||
{
|
||||
propertyId: '0000001',
|
||||
name: 'Hacienda San Angel',
|
||||
areas: 'Hotel, Restaurant',
|
||||
},
|
||||
]);
|
||||
|
||||
//Función para añadir una propiedad falsa (puedes adaptarla luego)
|
||||
const handleAddProperty = () => {
|
||||
const newId = String(properties.length + 1).padStart(7, '0'); // e.g. "0000002"
|
||||
const newProperty = {
|
||||
propertyId: newId,
|
||||
name: `New Property ${newId}`,
|
||||
areas: 'TBD',
|
||||
};
|
||||
setProperties([...properties, newProperty]);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'propertyId',
|
||||
header: lang === "en" ? 'PROPERTY ID' : 'ID DE PROPIEDAD',
|
||||
render: (value, row) => (
|
||||
<a href={`/app/properties/${value}`}>{value}</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: lang === "en" ? 'NAME' : 'NOMBRE',
|
||||
},
|
||||
{
|
||||
key: 'areas',
|
||||
header: lang === "en" ? 'AREAS' : 'ÁREAS',
|
||||
},
|
||||
];
|
||||
|
||||
//Agregamos la fila del botón como última fila (solo visual)
|
||||
const tableData = [...properties, { propertyId: 'add-row' }];
|
||||
|
||||
return (
|
||||
<div className="properties-container">
|
||||
<h2>{lang === "en" ? "Properties" : "Propiedades"}</h2>
|
||||
<Table
|
||||
columns={columns.map((col) => ({
|
||||
...col,
|
||||
render: (value, row) => {
|
||||
if (row.propertyId === 'add-row') {
|
||||
if (col.key === 'propertyId') {
|
||||
return (
|
||||
<button onClick={handleAddProperty} className="add-button">
|
||||
+ {lang === "en" ? "Add property…" : "Agregar propiedad…"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
//render personalizado
|
||||
return col.render
|
||||
? col.render(row[col.key], row)
|
||||
: row[col.key];
|
||||
},
|
||||
}))}
|
||||
data={tableData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// export default function Properties(){
|
||||
// <h2>Properties</h2>
|
||||
// };
|
||||
|
||||
// import React from 'react';
|
||||
// import Table from '../../components/Table/HotelTable'; // tu componente reutilizable
|
||||
|
||||
// export default function PropertiesPage() {
|
||||
// const columns = [
|
||||
// {
|
||||
// key: 'propertyId',
|
||||
// header: 'PROPERTY ID',
|
||||
// render: (value, row) => {
|
||||
// if (value === 'add') {
|
||||
// return <span style={{ fontWeight: 'bold' }}>+ Add property…</span>;
|
||||
// }
|
||||
// return <a href={`/properties/${value}`}>{value}</a>;
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// key: 'name',
|
||||
// header: 'NAME',
|
||||
// render: (value, row) => row.propertyId === 'add' ? null : value,
|
||||
// },
|
||||
// {
|
||||
// key: 'areas',
|
||||
// header: 'AREAS',
|
||||
// render: (value, row) => row.propertyId === 'add' ? null : value,
|
||||
// },
|
||||
// ];
|
||||
|
||||
// const data = [
|
||||
// {
|
||||
// propertyId: '0000001',
|
||||
// name: 'Hacienda San Angel',
|
||||
// areas: 'Hotel, Restaurant',
|
||||
// },
|
||||
// {
|
||||
// propertyId: 'add',
|
||||
// name: '+ Add property…',
|
||||
// areas: '',
|
||||
// },
|
||||
// ];
|
||||
|
||||
// return (
|
||||
// <div className="properties-container">
|
||||
// <h2>Properties</h2>
|
||||
// <Table columns={columns} data={data} />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
101
frontend/Frontend-Hotel/src/pages/Hotel/PropertiesId.jsx
Normal file
101
frontend/Frontend-Hotel/src/pages/Hotel/PropertiesId.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import './PropertyDetailPage.css';
|
||||
|
||||
export default function PropertyDetailPage() {
|
||||
const { id } = useParams();
|
||||
|
||||
// Simulación de datos por ID
|
||||
const [property, setProperty] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Aquí normalmente iría un fetch a backend con el id
|
||||
// Simulamos los datos por ahora
|
||||
const mockData = {
|
||||
propertyId: id,
|
||||
name: 'Hacienda San Angel',
|
||||
rfc: 'XAXX010101000',
|
||||
phone: '33-33-33-33-33',
|
||||
address: 'Example 110, Puerto Vallarta, Jalisco',
|
||||
areas: ['Hotel', 'Restaurant'],
|
||||
mainArea: 'Hotel',
|
||||
services: [
|
||||
{ name: 'ELECTRICITY', percent: 80 },
|
||||
{ name: 'WATER', percent: 90 },
|
||||
{ name: 'GAS', percent: 60 },
|
||||
],
|
||||
};
|
||||
setProperty(mockData);
|
||||
}, [id]);
|
||||
|
||||
const handleAddService = () => {
|
||||
const newService = { name: 'NEW SERVICE', percent: 50 };
|
||||
setProperty((prev) => ({
|
||||
...prev,
|
||||
services: [...prev.services, newService],
|
||||
}));
|
||||
};
|
||||
|
||||
if (!property) return <p>Loading...</p>;
|
||||
|
||||
return (
|
||||
<div className="property-detail-container">
|
||||
<h2>Property {property.propertyId}</h2>
|
||||
|
||||
<div className="property-info-box">
|
||||
<div className="property-info">
|
||||
<p><strong>ID:</strong> #{property.propertyId}</p>
|
||||
<p><strong>{property.name}</strong></p>
|
||||
<p><strong>RFC:</strong> <input type="text" defaultValue={property.rfc} /></p>
|
||||
<p><strong>Areas:</strong>
|
||||
<select multiple defaultValue={property.areas}>
|
||||
<option>Hotel</option>
|
||||
<option>Restaurant</option>
|
||||
<option>Bar</option>
|
||||
<option>Spa</option>
|
||||
</select>
|
||||
</p>
|
||||
<p><strong>Main area:</strong>
|
||||
<select defaultValue={property.mainArea}>
|
||||
<option>Hotel</option>
|
||||
<option>Restaurant</option>
|
||||
<option>Bar</option>
|
||||
<option>Spa</option>
|
||||
</select>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="property-contact">
|
||||
<p><strong>Phone number:</strong> <input type="text" defaultValue={property.phone} /></p>
|
||||
<p><strong>Address:</strong> {property.address}</p>
|
||||
<button className="save-button">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SERVICE</th>
|
||||
<th>%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{property.services.map((service, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>{service.name}</td>
|
||||
<td>{service.percent}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td>
|
||||
<button onClick={handleAddService} className="add-button">
|
||||
+ Add service…
|
||||
</button>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
.property-detail-container {
|
||||
padding: 20px;
|
||||
font-family: 'Trebuchet MS', sans-serif;
|
||||
}
|
||||
|
||||
.property-info-box {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: #f8f8f8;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.property-info p,
|
||||
.property-contact p {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.property-contact {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
input, select {
|
||||
padding: 5px;
|
||||
margin-left: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background-color: #f2c300;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
253
frontend/Frontend-Hotel/src/pages/Income/IncomeReport.css
Normal file
253
frontend/Frontend-Hotel/src/pages/Income/IncomeReport.css
Normal file
@@ -0,0 +1,253 @@
|
||||
.income-report-page {
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: #1a1a1a;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 12px 16px;
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #ef9a9a;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filters-section .date-range-filter {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
min-width: 180px;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.filter-select:hover {
|
||||
box-shadow: 0 0 0 2px #e6e6e6;
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
padding: 10px 20px;
|
||||
background-color: white;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.clear-filters-btn:hover {
|
||||
background-color: #f5f5f5;
|
||||
border-color: #ccc;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.summary-actions-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 24px;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary-card-wrapper {
|
||||
flex: 0 0 auto;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.new-income-btn {
|
||||
padding: 12px 24px;
|
||||
background-color: #5d1a2a;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 30px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(93, 26, 42, 0.3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.new-income-btn:hover {
|
||||
background-color: #7a2236;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(93, 26, 42, 0.4);
|
||||
}
|
||||
|
||||
.table-section {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.table-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-spinner-large {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 5px solid rgba(0, 0, 0, 0.1);
|
||||
border-top-color: #5d1a2a;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.table-loading p {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.additional-filters {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.income-report-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary-actions-section {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.summary-card-wrapper {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.new-income-btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.table-loading {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.loading-spinner-large {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
393
frontend/Frontend-Hotel/src/pages/Income/IncomeReport.jsx
Normal file
393
frontend/Frontend-Hotel/src/pages/Income/IncomeReport.jsx
Normal file
@@ -0,0 +1,393 @@
|
||||
import React, { useEffect, useState, useContext } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { langContext } from "../../context/LenguageContext";
|
||||
import DateRangeFilter from "../../components/Filters/DateRangeFilter";
|
||||
import SummaryCard from "../../components/SummaryCard";
|
||||
import ExcelExportButton from "../../components/ExcelExportButton";
|
||||
import "../../components/Filters/Filters.css";
|
||||
import "./IncomeReport.css";
|
||||
import Table from "../../components/Table/HotelTable";
|
||||
|
||||
export default function IncomeReport() {
|
||||
const navigate = useNavigate();
|
||||
const { lang } = useContext(langContext);
|
||||
const [incomeData, setIncomeData] = useState([]);
|
||||
const [filteredData, setFilteredData] = useState([]);
|
||||
const [dateRange, setDateRange] = useState({ from: "", to: "" });
|
||||
const [accountFilter, setAccountFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAccounts() {
|
||||
try {
|
||||
await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/incomeshrx/stripedata`
|
||||
);
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/incomeshrx/accountincome`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const data = Array.isArray(result) ? result : result.data || [];
|
||||
setAccounts(data);
|
||||
} catch (err) {
|
||||
console.error("Error fetching accounts:", err);
|
||||
setAccounts([]);
|
||||
}
|
||||
}
|
||||
|
||||
fetchAccounts();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchIncomeData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/incomeshrx/incomehorux`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
const data = Array.isArray(result) ? result : result.data || [];
|
||||
|
||||
const mappedData = data.map((item) => ({
|
||||
id_hrx_income: item.id_hrx_income,
|
||||
date: item.date_in,
|
||||
created_at: item.createddate || item.date_in,
|
||||
account: item.account_name,
|
||||
amount: item.account_name === "STRIPE" ? parseFloat(item.amountinvoice || 0).toFixed(2) : parseFloat(item.amount || 0).toFixed(2),
|
||||
invoiceAmount: item.account_name === "STRIPE" ? parseFloat(item.amount || 0).toFixed(2) : parseFloat(item.amountinvoice || 0).toFixed(2),
|
||||
invoices: item.invoice,
|
||||
status: item.status_in ? "Distributed" : "Pending",
|
||||
categories: item.categories || [],
|
||||
}));
|
||||
|
||||
setIncomeData(mappedData);
|
||||
setFilteredData(mappedData);
|
||||
} catch (err) {
|
||||
console.error("Error fetching income data:", err);
|
||||
setError(
|
||||
lang === "es"
|
||||
? "Error al cargar los datos de ingresos. Por favor, intente de nuevo."
|
||||
: "Failed to load income data. Please try again."
|
||||
);
|
||||
setIncomeData([]);
|
||||
setFilteredData([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchIncomeData();
|
||||
}, [lang]);
|
||||
|
||||
useEffect(() => {
|
||||
let filtered = [...incomeData];
|
||||
|
||||
if (dateRange.from && dateRange.to) {
|
||||
filtered = filtered.filter((item) => {
|
||||
const itemDate = item.created_at?.slice(0, 10);
|
||||
|
||||
if (!itemDate) return false;
|
||||
|
||||
return itemDate >= dateRange.from && itemDate <= dateRange.to;
|
||||
});
|
||||
}
|
||||
|
||||
if (accountFilter) {
|
||||
filtered = filtered.filter((item) => item.account === accountFilter);
|
||||
}
|
||||
|
||||
if (statusFilter) {
|
||||
filtered = filtered.filter((item) => item.status === statusFilter);
|
||||
}
|
||||
|
||||
setFilteredData(filtered);
|
||||
}, [dateRange, accountFilter, statusFilter, incomeData]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setDateRange({ from: "", to: "" });
|
||||
setAccountFilter("");
|
||||
setStatusFilter("");
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return "";
|
||||
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return "";
|
||||
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const year = date.getFullYear();
|
||||
|
||||
return `${day}/${month}/${year}`;
|
||||
};
|
||||
|
||||
const handleStatusClick = (label, row) => {
|
||||
if (label === "Pending") {
|
||||
navigate(`/app/edit-income-form/${row.id}`, {
|
||||
state: {
|
||||
incomeData: {
|
||||
id: row.id,
|
||||
date: row.created_at,
|
||||
account: row.account,
|
||||
amount: row.amount,
|
||||
invoiceAmount: row.invoiceAmount,
|
||||
invoices: row.invoices,
|
||||
status: row.status,
|
||||
categories: row.categories
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const totalIncome = filteredData.reduce((sum, item) => {
|
||||
return sum + parseFloat(item.amount || 0);
|
||||
}, 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
const statusOptions = ["Distributed", "Pending"];
|
||||
|
||||
const columns = [
|
||||
{ header: lang === "es" ? "ID" : "ID", key: "id_hrx_income" },
|
||||
{
|
||||
header: lang === "es" ? "FECHA" : "DATE",
|
||||
key: "created_at",
|
||||
render: (value) => formatDate(value),
|
||||
},
|
||||
{
|
||||
header: lang === "es" ? "CUENTA" : "ACCOUNT",
|
||||
key: "account",
|
||||
render: (value, row) => (
|
||||
<span
|
||||
onClick={() => navigate(`/app/edit-income-form/${row.id}`, {
|
||||
state: {
|
||||
incomeData: {
|
||||
id: row.id,
|
||||
date: row.created_at,
|
||||
account: row.account,
|
||||
amount: row.amount,
|
||||
invoiceAmount: row.invoiceAmount,
|
||||
invoices: row.invoices,
|
||||
status: row.status,
|
||||
categories: row.categories
|
||||
}
|
||||
}
|
||||
})}
|
||||
style={{
|
||||
color: "#5D1A2A",
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: lang === "es" ? "MONTO" : "AMOUNT",
|
||||
key: "amount",
|
||||
render: (value) => {
|
||||
const num = parseFloat(value);
|
||||
return `$${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: lang === "es" ? "MONTO FACTURA" : "INVOICE AMOUNT",
|
||||
key: "invoiceAmount",
|
||||
render: (value) => {
|
||||
const num = parseFloat(value);
|
||||
return `$${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: lang === "es" ? "FACTURA" : "INVOICE",
|
||||
key: "invoices",
|
||||
},
|
||||
{
|
||||
header: lang === "es" ? "CATEGORÍAS" : "CATEGORIES",
|
||||
key: "categories",
|
||||
render: (value, row) => {
|
||||
if (!row.categories || row.categories.length === 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return row.categories.map((category, index) => {
|
||||
const total = parseFloat(category.total || 0);
|
||||
const formattedTotal = `$${total.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
return (
|
||||
<div key={index} style={{ marginBottom: index < row.categories.length - 1 ? '4px' : '0' }}>
|
||||
{category.category}: {formattedTotal}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
header: lang === "es" ? "ESTADO" : "STATUS",
|
||||
key: "status",
|
||||
headerStyle: { textAlign: "center" },
|
||||
render: (value, row) => (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<button
|
||||
onClick={() => handleStatusClick(value, row)}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontWeight: "bold",
|
||||
backgroundColor: value === "Distributed" ? "#4CAF50" : "#FFC107",
|
||||
color: value === "Distributed" ? "white" : "black",
|
||||
}}
|
||||
>
|
||||
{lang === "es"
|
||||
? value === "Distributed"
|
||||
? "Distribuido"
|
||||
: "Pendiente"
|
||||
: value}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const exportColumns = [
|
||||
{ header: lang === "es" ? "ID" : "ID", key: "id_hrx_income" },
|
||||
{ header: lang === "es" ? "FECHA" : "DATE", key: "created_at" },
|
||||
{ header: lang === "es" ? "CUENTA" : "ACCOUNT", key: "account" },
|
||||
{ header: lang === "es" ? "MONTO" : "AMOUNT", key: "amount" },
|
||||
{ header: lang === "es" ? "MONTO FACTURA" : "INVOICE AMOUNT", key: "invoiceAmount" },
|
||||
{ header: lang === "es" ? "FACTURA" : "INVOICE", key: "invoices" },
|
||||
{ header: lang === "es" ? "CATEGORÍAS" : "CATEGORIES", key: "categories" },
|
||||
{ header: lang === "es" ? "ESTADO" : "STATUS", key: "status" },
|
||||
];
|
||||
|
||||
const dataTransform = (data) => {
|
||||
return data.map((row) => ({
|
||||
...row,
|
||||
created_at: formatDate(row.created_at),
|
||||
amount: row.amount ? `$${parseFloat(row.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : '',
|
||||
invoiceAmount: row.invoiceAmount ? `$${parseFloat(row.invoiceAmount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : '',
|
||||
categories: row.categories && row.categories.length > 0
|
||||
? row.categories.map(cat => {
|
||||
const total = parseFloat(cat.total || 0);
|
||||
const formattedTotal = `$${total.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
return `${cat.category}: ${formattedTotal}`;
|
||||
}).join('; ')
|
||||
: '',
|
||||
status: lang === "es"
|
||||
? (row.status === "Distributed" ? "Distribuido" : "Pendiente")
|
||||
: row.status,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="income-report-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
{lang === "es" ? "Informe de Ingresos" : "Income Report"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="summary-actions-section">
|
||||
<div className="summary-card-wrapper">
|
||||
<SummaryCard
|
||||
title={lang === "es" ? "Total de Ingresos" : "Total Income"}
|
||||
amount={totalIncome}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</div>
|
||||
<Link to="/app/new-income-form" className="new-income-btn">
|
||||
{lang === "es" ? "+ Nuevo Ingreso" : "+ New Income"}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="filters-section">
|
||||
<DateRangeFilter
|
||||
dateRange={dateRange}
|
||||
onDateChange={setDateRange}
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={accountFilter}
|
||||
onChange={(e) => setAccountFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">
|
||||
{lang === "es" ? "Todas las cuentas" : "All Accounts"}
|
||||
</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id_acc_income} value={account.name_acc_income}>
|
||||
{account.name_acc_income}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">
|
||||
{lang === "es" ? "Todos los estados" : "All Status"}
|
||||
</option>
|
||||
{statusOptions.map((status, index) => (
|
||||
<option key={index} value={status}>
|
||||
{lang === "es"
|
||||
? status === "Distributed"
|
||||
? "Distribuido"
|
||||
: "Pendiente"
|
||||
: status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button onClick={clearFilters} className="clear-filters-btn">
|
||||
{lang === "es" ? "Limpiar filtros" : "Clear filters"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '15px' }}>
|
||||
<ExcelExportButton
|
||||
data={filteredData}
|
||||
columns={exportColumns}
|
||||
filenamePrefix={lang === "es" ? "informe-ingresos" : "income-report"}
|
||||
sheetName={lang === "es" ? "Informe de Ingresos" : "Income Report"}
|
||||
dataTransform={dataTransform}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="table-section">
|
||||
{loading ? (
|
||||
<div className="table-loading">
|
||||
<div className="loading-spinner-large"></div>
|
||||
<p>{lang === "es" ? "Cargando..." : "Loading..."}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table columns={columns} data={filteredData} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
281
frontend/Frontend-Hotel/src/pages/Income/NewIncome.css
Normal file
281
frontend/Frontend-Hotel/src/pages/Income/NewIncome.css
Normal file
@@ -0,0 +1,281 @@
|
||||
.new-income-container {
|
||||
color: #333;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background-color: #6c757d;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.back-button:active {
|
||||
background-color: #545b62;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-grid label {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-grid input,
|
||||
.form-grid select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background-color: #f9f9f9;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-grid input:focus,
|
||||
.form-grid select:focus {
|
||||
outline: none;
|
||||
border-color: #ffcb05;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.form-grid input[type="number"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.save-button-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background-color: #ffcb05;
|
||||
color: #fff !important;
|
||||
padding: 12px 30px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.save-button:hover:not(:disabled) {
|
||||
background-color: #f4b400;
|
||||
}
|
||||
|
||||
.save-button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.income-categories {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.income-categories h3 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.income-categories > div {
|
||||
border: 1px solid #ccc;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.income-categories .category-item {
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px dotted #eee;
|
||||
padding-bottom: 5px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.category-item > div {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.category-item label {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.category-item select,
|
||||
.category-item input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background-color: #f9f9f9;
|
||||
transition: border-color 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.category-item input[readonly] {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.category-item input:focus,
|
||||
.category-item select:focus {
|
||||
outline: none;
|
||||
border-color: #ffcb05;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.taxes-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.taxes-container label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.taxes-container input[type="checkbox"] {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
.total-income-summary {
|
||||
margin-top: 20px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #f9f9f9;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.new-income-container {
|
||||
padding: 10px;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 5px !important;
|
||||
}
|
||||
|
||||
.taxes-container {
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.taxes-container label {
|
||||
margin: 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.save-button.disabled-validation {
|
||||
background-color: #808080 !important;
|
||||
color: #fff !important;
|
||||
cursor: not-allowed !important;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.save-button.disabled-validation:hover {
|
||||
background-color: #dc3545 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
899
frontend/Frontend-Hotel/src/pages/Income/NewIncome.jsx
Normal file
899
frontend/Frontend-Hotel/src/pages/Income/NewIncome.jsx
Normal file
@@ -0,0 +1,899 @@
|
||||
import React, { useState, useEffect, useRef, useContext } from "react";
|
||||
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||||
import { langContext } from "../../context/LenguageContext";
|
||||
import "./NewIncome.css";
|
||||
|
||||
export default function NewIncome() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const location = useLocation();
|
||||
const { lang } = useContext(langContext);
|
||||
const isEditMode = Boolean(id);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
account_id: "",
|
||||
date: "",
|
||||
amount: "",
|
||||
invoice: "",
|
||||
categories: [],
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingData, setLoadingData] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [categoryOptions, setCategoryOptions] = useState([]);
|
||||
const [invoiceOptions, setInvoiceOptions] = useState([]);
|
||||
const [invoiceSearchTerm, setInvoiceSearchTerm] = useState("");
|
||||
const [showInvoiceDropdown, setShowInvoiceDropdown] = useState(false);
|
||||
const [filteredInvoices, setFilteredInvoices] = useState([]);
|
||||
const [isAmountDisabled, setIsAmountDisabled] = useState(false);
|
||||
|
||||
const invoiceInputRef = useRef(null);
|
||||
const invoiceDropdownRef = useRef(null);
|
||||
|
||||
const taxOptions = [
|
||||
{ value: 0, label: "0%" },
|
||||
{ value: 8, label: "8%" },
|
||||
{ value: 16, label: "16%" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAccounts() {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/incomeshrx/accountincome`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
const data = Array.isArray(result) ? result : (result.data || []);
|
||||
setAccounts(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching accounts:', err);
|
||||
setAccounts([]);
|
||||
}
|
||||
}
|
||||
fetchAccounts();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/incomeshrx/categoryincome`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
const data = Array.isArray(result) ? result : (result.data || []);
|
||||
setCategoryOptions(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching categories:', err);
|
||||
setCategoryOptions([]);
|
||||
}
|
||||
}
|
||||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchInvoices() {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/incomeshrx/invoiceIncome`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
const data = Array.isArray(result) ? result : (result.data || []);
|
||||
setInvoiceOptions(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching invoices:', err);
|
||||
setInvoiceOptions([]);
|
||||
}
|
||||
}
|
||||
fetchInvoices();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (invoiceSearchTerm.trim() === "") {
|
||||
setFilteredInvoices(invoiceOptions);
|
||||
} else {
|
||||
const filtered = invoiceOptions.filter((invoice) =>
|
||||
invoice.name_inv_income.toLowerCase().includes(invoiceSearchTerm.toLowerCase())
|
||||
);
|
||||
setFilteredInvoices(filtered);
|
||||
}
|
||||
}, [invoiceSearchTerm, invoiceOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (
|
||||
invoiceInputRef.current &&
|
||||
!invoiceInputRef.current.contains(event.target) &&
|
||||
invoiceDropdownRef.current &&
|
||||
!invoiceDropdownRef.current.contains(event.target)
|
||||
) {
|
||||
setShowInvoiceDropdown(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode) {
|
||||
setForm({
|
||||
account_id: "",
|
||||
date: "",
|
||||
amount: "",
|
||||
invoice: "",
|
||||
categories: [{
|
||||
categoryType: "",
|
||||
amount: "",
|
||||
selectedTax: null,
|
||||
total: "0.00",
|
||||
}],
|
||||
});
|
||||
setInvoiceSearchTerm("");
|
||||
setSuccess(false);
|
||||
setError("");
|
||||
}
|
||||
}, [isEditMode]);
|
||||
|
||||
const calculateSelectedTax = (amount, tax) => {
|
||||
if (!amount || amount === 0) return null;
|
||||
|
||||
const taxPercentage = Math.round((tax / amount) * 100);
|
||||
const availableTaxes = [16, 8, 0];
|
||||
|
||||
for (const taxValue of availableTaxes) {
|
||||
if (taxPercentage === taxValue) {
|
||||
return taxValue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode || !id) return;
|
||||
|
||||
const navigationData = location.state?.incomeData;
|
||||
|
||||
if (navigationData) {
|
||||
try {
|
||||
setLoadingData(true);
|
||||
setError("");
|
||||
|
||||
|
||||
const formattedDate = navigationData.date
|
||||
? new Date(navigationData.date).toISOString().slice(0, 10)
|
||||
: "";
|
||||
|
||||
const mappedCategories = navigationData.categories?.map((cat) => {
|
||||
const catAmount = parseFloat(cat.amount) || 0;
|
||||
const catTax = parseFloat(cat.tax) || 0;
|
||||
const selectedTax = calculateSelectedTax(catAmount, catTax);
|
||||
|
||||
return {
|
||||
categoryType: String(cat.id_category || cat.id_cat_income || ""),
|
||||
amount: String(cat.amount || ""),
|
||||
selectedTax: selectedTax,
|
||||
total: String(cat.total || "0.00"),
|
||||
};
|
||||
}) || [{
|
||||
categoryType: "",
|
||||
amount: "",
|
||||
selectedTax: null,
|
||||
total: "0.00",
|
||||
}];
|
||||
|
||||
const invoiceString = navigationData.invoices;
|
||||
let matchedInvoice = null;
|
||||
let isAmountDisabled = false;
|
||||
|
||||
if (invoiceString && invoiceOptions.length > 0) {
|
||||
matchedInvoice = invoiceOptions.find(
|
||||
(inv) => String(inv.id_inv_income) === invoiceString ||
|
||||
inv.name_inv_income === invoiceString
|
||||
);
|
||||
|
||||
if (matchedInvoice) {
|
||||
isAmountDisabled = true;
|
||||
setInvoiceSearchTerm(matchedInvoice.name_inv_income);
|
||||
} else {
|
||||
isAmountDisabled = false;
|
||||
setInvoiceSearchTerm(invoiceString);
|
||||
}
|
||||
}
|
||||
|
||||
setIsAmountDisabled(isAmountDisabled);
|
||||
|
||||
const selectedAccount = accounts.find(
|
||||
(acc) => acc.name_acc_income === navigationData.account
|
||||
);
|
||||
const accountId = selectedAccount ? String(selectedAccount.id_acc_income) : "";
|
||||
|
||||
setForm({
|
||||
account_id: accountId,
|
||||
date: formattedDate,
|
||||
amount: String(navigationData.amount || ""),
|
||||
invoice: invoiceString || "",
|
||||
categories: mappedCategories,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error processing navigation data:", err);
|
||||
setError(lang === "en" ? "Failed to load income data for editing." : "Error al cargar datos del ingreso para editar.");
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
} else {
|
||||
console.warn("No navigation data found, this shouldn't happen");
|
||||
setError(lang === "en" ? "No data available for editing." : "No hay datos disponibles para editar.");
|
||||
setLoadingData(false);
|
||||
}
|
||||
}, [isEditMode, id, invoiceOptions, accounts, location.state, lang]);
|
||||
|
||||
const isAccountCash = () => {
|
||||
if (!form.account_id) return false;
|
||||
const selectedAccount = accounts.find(
|
||||
(acc) => String(acc.id_acc_income) === String(form.account_id)
|
||||
);
|
||||
return selectedAccount?.name_acc_income === "CASH";
|
||||
};
|
||||
|
||||
const getCurrentAccountName = () => {
|
||||
if (!form.account_id) return "";
|
||||
const selectedAccount = accounts.find(
|
||||
(acc) => String(acc.id_acc_income) === String(form.account_id)
|
||||
);
|
||||
return selectedAccount?.name_acc_income || "";
|
||||
};
|
||||
|
||||
const getFilteredAccounts = () => {
|
||||
if (isEditMode) {
|
||||
const currentAccountName = getCurrentAccountName();
|
||||
if (currentAccountName === "STRIPE") {
|
||||
return accounts;
|
||||
}
|
||||
return accounts.filter((account) => account.name_acc_income !== "STRIPE");
|
||||
} else {
|
||||
return accounts.filter((account) => account.name_acc_income !== "STRIPE");
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
if (error) setError("");
|
||||
};
|
||||
|
||||
const handleInvoiceInputChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setInvoiceSearchTerm(value);
|
||||
setShowInvoiceDropdown(true);
|
||||
|
||||
const matchedInvoice = invoiceOptions.find(
|
||||
(inv) => String(inv.id_inv_income) === value ||
|
||||
inv.name_inv_income === value
|
||||
);
|
||||
|
||||
if (matchedInvoice) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
invoice: String(matchedInvoice.name_inv_income)
|
||||
}));
|
||||
} else {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
invoice: value,
|
||||
...(isEditMode ? {} : { amount: "" })
|
||||
}));
|
||||
}
|
||||
|
||||
if (error) setError("");
|
||||
};
|
||||
|
||||
const handleInvoiceSelect = (invoice) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
invoice: String(invoice.name_inv_income)
|
||||
}));
|
||||
setInvoiceSearchTerm(invoice.name_inv_income);
|
||||
setShowInvoiceDropdown(false);
|
||||
if (error) setError("");
|
||||
};
|
||||
|
||||
const handleInvoiceFocus = () => {
|
||||
setShowInvoiceDropdown(true);
|
||||
};
|
||||
|
||||
const handleCategoryChange = (index, field, value) => {
|
||||
const updatedCategories = [...form.categories];
|
||||
updatedCategories[index][field] = value;
|
||||
|
||||
if (field === "amount" || field === "selectedTax") {
|
||||
const amount = parseFloat(updatedCategories[index].amount) || 0;
|
||||
const selectedTax = updatedCategories[index].selectedTax;
|
||||
const taxPercentage = selectedTax ? selectedTax / 100 : 0;
|
||||
const taxAmount = amount * taxPercentage;
|
||||
const total = amount + taxAmount;
|
||||
|
||||
updatedCategories[index].total = total.toFixed(2);
|
||||
}
|
||||
|
||||
if (!isEditMode) {
|
||||
const newTotalIncome = updatedCategories.reduce((total, category) => {
|
||||
return total + (parseFloat(category.total) || 0);
|
||||
}, 0);
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
categories: updatedCategories,
|
||||
amount: newTotalIncome.toFixed(2),
|
||||
}));
|
||||
} else {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
categories: updatedCategories,
|
||||
}));
|
||||
}
|
||||
|
||||
if (error) setError("");
|
||||
};
|
||||
|
||||
const handleAddCategory = () => {
|
||||
const newCategory = {
|
||||
categoryType: "",
|
||||
amount: "",
|
||||
selectedTax: null,
|
||||
total: "0.00",
|
||||
};
|
||||
|
||||
setForm((prev) => {
|
||||
const updatedCategories = [...prev.categories, newCategory];
|
||||
|
||||
if (!isEditMode) {
|
||||
const newTotalIncome = updatedCategories.reduce((total, category) => {
|
||||
return total + (parseFloat(category.total) || 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
categories: updatedCategories,
|
||||
amount: newTotalIncome.toFixed(2),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...prev,
|
||||
categories: updatedCategories,
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveCategory = (index) => {
|
||||
if (form.categories.length > 1) {
|
||||
setForm((prev) => {
|
||||
const updatedCategories = prev.categories.filter((_, i) => i !== index);
|
||||
|
||||
if (!isEditMode) {
|
||||
const newTotalIncome = updatedCategories.reduce((total, category) => {
|
||||
return total + (parseFloat(category.total) || 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
categories: updatedCategories,
|
||||
amount: newTotalIncome.toFixed(2),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...prev,
|
||||
categories: updatedCategories,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const calculateTotalIncome = () => {
|
||||
return form.categories
|
||||
.reduce((total, category) => {
|
||||
return total + (parseFloat(category.total) || 0);
|
||||
}, 0)
|
||||
.toFixed(2);
|
||||
};
|
||||
|
||||
const isUpdateButtonEnabled = () => {
|
||||
if (!isEditMode) return true;
|
||||
|
||||
const totalIncome = parseFloat(calculateTotalIncome());
|
||||
const amountValue = parseFloat(form.amount) || 0;
|
||||
const difference = Math.abs(totalIncome - amountValue);
|
||||
|
||||
return difference <= 2;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (form.categories.length === 0) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
categories: [
|
||||
{
|
||||
categoryType: "",
|
||||
amount: "",
|
||||
selectedTax: null,
|
||||
total: "0.00",
|
||||
},
|
||||
],
|
||||
amount: "0.00",
|
||||
}));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const validateForm = () => {
|
||||
if (!form.account_id) {
|
||||
setError(lang === 'en' ? "Account is required" : "La cuenta es requerida");
|
||||
return false;
|
||||
}
|
||||
if (!form.date) {
|
||||
setError(lang === 'en' ? "Date is required" : "La fecha es requerida");
|
||||
return false;
|
||||
}
|
||||
if (!isAccountCash() && (!form.amount || parseFloat(form.amount) <= 0)) {
|
||||
setError(lang === 'en' ? "Amount must be a positive number" : "El monto debe ser un número positivo");
|
||||
return false;
|
||||
}
|
||||
if (!isAccountCash() && !form.invoice) {
|
||||
setError(lang === 'en' ? "Invoice is required" : "La factura es requerida");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (form.categories.length === 0) {
|
||||
setError(lang === 'en' ? "At least one category is required" : "Se requiere al menos una categoría");
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < form.categories.length; i++) {
|
||||
const category = form.categories[i];
|
||||
if (!category.categoryType) {
|
||||
setError(lang === 'en'
|
||||
? `Category ${i + 1}: Category type is required`
|
||||
: `Categoría ${i + 1}: El tipo de categoría es requerido`);
|
||||
return false;
|
||||
}
|
||||
if (!category.amount || parseFloat(category.amount) <= 0) {
|
||||
setError(lang === 'en'
|
||||
? `Category ${i + 1}: Amount must be a positive number`
|
||||
: `Categoría ${i + 1}: El monto debe ser un número positivo`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAccountCash() && isEditMode) {
|
||||
const totalIncome = parseFloat(calculateTotalIncome());
|
||||
const amountValue = parseFloat(form.amount) || 0;
|
||||
const difference = Math.abs(totalIncome - amountValue);
|
||||
|
||||
if (difference > 2) {
|
||||
setError(lang === 'en'
|
||||
? "The total income is not the same as the Amount input value"
|
||||
: "El ingreso total no es igual al valor del campo Monto");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const formatDateForAPI = (dateStr) => {
|
||||
if (!dateStr) return "";
|
||||
return dateStr;
|
||||
};
|
||||
|
||||
const formatDateForUpdate = (dateStr) => {
|
||||
if (!dateStr) return "";
|
||||
return dateStr;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const formattedCategories = form.categories.map((cat) => {
|
||||
const amount = parseFloat(cat.amount) || 0;
|
||||
const selectedTax = cat.selectedTax || 0;
|
||||
const taxPercentage = selectedTax / 100;
|
||||
const taxAmount = amount * taxPercentage;
|
||||
const total = amount + taxAmount;
|
||||
|
||||
return {
|
||||
id_category: parseInt(cat.categoryType) || 0,
|
||||
amount: amount,
|
||||
tax: parseFloat(taxAmount.toFixed(2)),
|
||||
total: parseFloat(total.toFixed(2)),
|
||||
};
|
||||
});
|
||||
|
||||
let response;
|
||||
|
||||
if (isEditMode) {
|
||||
|
||||
const payload = {
|
||||
account_id: parseInt(form.account_id),
|
||||
amount: parseFloat(form.amount),
|
||||
up_date: formatDateForUpdate(form.date),
|
||||
up_invoice: form.invoice && form.invoice.trim() ? form.invoice : null,
|
||||
categories: formattedCategories,
|
||||
};
|
||||
|
||||
|
||||
|
||||
response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/incomeshrx/updateincome/${id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
|
||||
const payload = {
|
||||
account_id: parseInt(form.account_id),
|
||||
amount: parseFloat(form.amount),
|
||||
new_date: formatDateForAPI(form.date),
|
||||
newinvoice: form.invoice && form.invoice.trim() ? form.invoice : null,
|
||||
categories: formattedCategories,
|
||||
};
|
||||
|
||||
|
||||
response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/incomeshrx/newincome`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
if (!isEditMode) {
|
||||
setForm({
|
||||
account_id: "",
|
||||
date: "",
|
||||
amount: "",
|
||||
invoice: "",
|
||||
categories: [],
|
||||
});
|
||||
setInvoiceSearchTerm("");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error submitting income:", err);
|
||||
setError(err.message || (lang === "en" ? "Failed to submit income. Please try again." : "Error al enviar el ingreso. Por favor inténtelo de nuevo."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="new-income-container">
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<h1>{isEditMode ? (lang === "en" ? "Edit Income" : "Editar Ingreso") : (lang === "en" ? "New Income" : "Nuevo Ingreso")}</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="back-button"
|
||||
onClick={() => navigate("/app/new-income-report")}
|
||||
>
|
||||
<span>←</span>
|
||||
{lang === "en" ? "Back" : "Volver"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="success-message">
|
||||
{isEditMode
|
||||
? (lang === "en" ? "Income updated successfully!" : "¡Ingreso actualizado exitosamente!")
|
||||
: (lang === "en" ? "Income submitted successfully!" : "¡Ingreso enviado exitosamente!")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{loadingData ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '40px',
|
||||
color: '#666'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '4px solid #f3f3f3',
|
||||
borderTop: '4px solid #5D1A2A',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
<span style={{ marginLeft: '12px' }}>{lang === "en" ? "Loading income data..." : "Cargando datos del ingreso..."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label htmlFor="account_id">{lang === "en" ? "Account *" : "Cuenta *"}</label>
|
||||
<select
|
||||
id="account_id"
|
||||
name="account_id"
|
||||
value={form.account_id}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={isEditMode}
|
||||
style={isEditMode ? { backgroundColor: '#f5f5f5', cursor: 'not-allowed' } : {}}
|
||||
>
|
||||
<option value="">{lang === "en" ? "Select Account" : "Seleccionar Cuenta"}</option>
|
||||
{getFilteredAccounts().map((account) => (
|
||||
<option key={account.id_acc_income} value={account.id_acc_income}>
|
||||
{account.name_acc_income}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="date">{lang === "en" ? "Date *" : "Fecha *"}</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
name="date"
|
||||
value={form.date}
|
||||
onChange={handleChange}
|
||||
disabled={isEditMode}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="amount">
|
||||
{lang === "en" ? "Amount" : "Monto"}{!isAccountCash() && '*'}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="amount"
|
||||
name="amount"
|
||||
value={form.amount}
|
||||
onChange={handleChange}
|
||||
placeholder="0.00"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required={!isAccountCash()}
|
||||
disabled={true}
|
||||
style={isEditMode && isAmountDisabled ? { backgroundColor: '#f5f5f5', cursor: 'not-allowed' } : {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ position: 'relative' }}>
|
||||
<label htmlFor="invoice">
|
||||
{lang === "en" ? "Invoice" : "Factura"}{!isAccountCash() && '*'}
|
||||
</label>
|
||||
<input
|
||||
ref={invoiceInputRef}
|
||||
type="text"
|
||||
id="invoice"
|
||||
name="invoice"
|
||||
value={invoiceSearchTerm}
|
||||
onChange={handleInvoiceInputChange}
|
||||
onFocus={handleInvoiceFocus}
|
||||
placeholder={lang === "en" ? "Type to search invoice..." : "Escriba para buscar factura..."}
|
||||
required={!isAccountCash()}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{showInvoiceDropdown && (
|
||||
<div
|
||||
ref={invoiceDropdownRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
marginTop: '4px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
{filteredInvoices.length > 0 ? (
|
||||
filteredInvoices.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id_inv_income}
|
||||
onClick={() => handleInvoiceSelect(invoice)}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'white'}
|
||||
>
|
||||
{invoice.name_inv_income}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ padding: '10px 12px', color: '#999', textAlign: 'center' }}>
|
||||
{lang === "en" ? "No invoices found" : "No se encontraron facturas"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="income-categories">
|
||||
<h3 style={{ marginTop: "20px" }}>{lang === "en" ? "Income Categories" : "Categorías de Ingreso"}</h3>
|
||||
|
||||
<div className="categories-container">
|
||||
{form.categories.map((category, index) => (
|
||||
<div key={index} className="category-item">
|
||||
<div>
|
||||
<label>{lang === "en" ? "Category Type:" : "Tipo de Categoría:"}</label>
|
||||
<select
|
||||
value={category.categoryType || ""}
|
||||
onChange={(e) =>
|
||||
handleCategoryChange(
|
||||
index,
|
||||
"categoryType",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{lang === "en" ? "Select Category Type" : "Seleccionar Tipo de Categoría"}
|
||||
</option>
|
||||
{categoryOptions
|
||||
.sort((a, b) => {
|
||||
const nameA = (a.name_category || a.name_cat_income || a.name || '').toLowerCase();
|
||||
const nameB = (b.name_category || b.name_cat_income || b.name || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map((cat) => (
|
||||
<option
|
||||
key={cat.id_category || cat.id_cat_income || cat.id}
|
||||
value={cat.id_category || cat.id_cat_income || cat.id}
|
||||
>
|
||||
{cat.name_category || cat.name_cat_income || cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Amount:" : "Monto:"}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={category.amount || ""}
|
||||
onChange={(e) =>
|
||||
handleCategoryChange(index, "amount", e.target.value)
|
||||
}
|
||||
placeholder="0.00"
|
||||
step="0.01"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Taxes:" : "Impuestos:"}</label>
|
||||
<div className="taxes-container">
|
||||
{taxOptions.map((tax) => (
|
||||
<label key={tax.value}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`tax-${index}`}
|
||||
checked={category.selectedTax === tax.value}
|
||||
onChange={() => {
|
||||
handleCategoryChange(
|
||||
index,
|
||||
"selectedTax",
|
||||
tax.value
|
||||
);
|
||||
}}
|
||||
style={{
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
{tax.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{lang === "en" ? "Total:" : "Total:"}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={category.total || "0.00"}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "end" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveCategory(index)}
|
||||
className="save-button"
|
||||
disabled={form.categories.length <= 1}
|
||||
>
|
||||
{lang === "en" ? "Remove Category" : "Eliminar Categoría"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCategory}
|
||||
className="save-button"
|
||||
>
|
||||
{lang === "en" ? "Add Category" : "Agregar Categoría"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="total-income-summary">
|
||||
<strong>{lang === "en" ? "Total Income:" : "Ingreso Total:"} ${calculateTotalIncome()}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="save-button-wrapper">
|
||||
<button
|
||||
type="submit"
|
||||
className={`save-button ${isEditMode && !isUpdateButtonEnabled() ? 'disabled-validation' : ''}`}
|
||||
disabled={loading || (isEditMode && !isUpdateButtonEnabled())}
|
||||
>
|
||||
{loading
|
||||
? (isEditMode ? (lang === "en" ? "Updating..." : "Actualizando...") : (lang === "en" ? "Submitting..." : "Enviando..."))
|
||||
: (isEditMode ? (lang === "en" ? "Update Income" : "Actualizar Ingreso") : (lang === "en" ? "Save Income" : "Guardar Ingreso"))}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
frontend/Frontend-Hotel/src/pages/Inventory/Adjustments.css
Normal file
86
frontend/Frontend-Hotel/src/pages/Inventory/Adjustments.css
Normal file
@@ -0,0 +1,86 @@
|
||||
.adjustments-page-container {
|
||||
padding: 20px;
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.adjustments-page-container h2 {
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.filters-section select {
|
||||
color: #213547;
|
||||
background-color: white;
|
||||
max-width: 250px;
|
||||
min-width: 150px;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.filters-section input[type="date"] {
|
||||
color: #213547;
|
||||
background-color: white;
|
||||
max-width: 200px;
|
||||
min-width: 150px;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.filters-section .filter-search {
|
||||
color: #213547;
|
||||
background-color: white;
|
||||
max-width: 300px;
|
||||
min-width: 200px;
|
||||
flex: 0 0 auto;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.filters-section .save-button {
|
||||
width: 120px;
|
||||
background: #F2C300;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
border-radius: 25px;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.save-button:hover:not(:disabled) {
|
||||
background: #ddb000;
|
||||
}
|
||||
|
||||
.save-button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filters-section {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filters-section .filter-search {
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filters-section .save-button {
|
||||
font-size: 13px;
|
||||
padding: 8px 16px;
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
269
frontend/Frontend-Hotel/src/pages/Inventory/Adjustments.jsx
Normal file
269
frontend/Frontend-Hotel/src/pages/Inventory/Adjustments.jsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React, { useState, useContext } from "react";
|
||||
import "../../components/Filters/Filters.css";
|
||||
import "./Adjustments.css";
|
||||
import Table from "../../components/Table/HotelTable copy";
|
||||
import { redirect } from "react-router-dom";
|
||||
import { langContext } from "../../context/LenguageContext";
|
||||
import ExcelExportButton from "../../components/ExcelExportButton";
|
||||
|
||||
//**////** */ */ VERIFICAR POR QUE MANDA DISCARD Y NO AJUSTMENT solo el endpoint /products/stockadjusmentset
|
||||
export default function Adjustments() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [adjustments, setAdjustments] = useState([]);
|
||||
const [productSearch, setProductSearch] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + "/products")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (Array.isArray(data.data)) {
|
||||
setAdjustments(
|
||||
data.data.map((prod) => ({
|
||||
productId: prod.id_product,
|
||||
product: prod.name_product,
|
||||
currentStock: Number(prod.units),
|
||||
realStock: prod.units,
|
||||
adjustment: 0,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setAdjustments([]);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRealStockChange = (productId, value) => {
|
||||
const numericValue = Number(value);
|
||||
const realStock =
|
||||
isNaN(numericValue) || numericValue < 0 ? 0 : numericValue;
|
||||
|
||||
const currentDate = new Date();
|
||||
const newAdjustments = adjustments.map((item) => {
|
||||
if (item.productId === productId) {
|
||||
return {
|
||||
...item,
|
||||
realStock,
|
||||
adjustment: realStock - item.currentStock,
|
||||
datediscard: `${currentDate.getDay}-${currentDate.getMonth}-${currentDate.getFullYear()}`,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
setAdjustments(newAdjustments);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: lang === "es" ? "ID DEL PRODUCTO" : "PRODUCT ID",
|
||||
key: "productId",
|
||||
},
|
||||
{ header: lang === "es" ? "PRODUCTO" : "PRODUCT", key: "product" },
|
||||
{
|
||||
header: lang === "es" ? "STOCK ACTUAL" : "CURRENT STOCK",
|
||||
key: "currentStock",
|
||||
},
|
||||
{
|
||||
header: lang === "es" ? "STOCK REAL" : "REAL STOCK",
|
||||
key: "realStock",
|
||||
render: (value, row) => {
|
||||
const isDisabled = !row.currentStock || Number(row.currentStock) <= 0 || saving;
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={row.realStock}
|
||||
min={0}
|
||||
disabled={isDisabled}
|
||||
title={
|
||||
saving
|
||||
? lang === "es"
|
||||
? "Guardando ajustes..."
|
||||
: "Saving adjustments..."
|
||||
: isDisabled
|
||||
? lang === "es"
|
||||
? "No se puede ajustar cuando el stock es 0"
|
||||
: "Cannot adjust when stock is 0"
|
||||
: ""
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "-" ||
|
||||
e.key === "e" ||
|
||||
e.key === "E" ||
|
||||
e.key === "+"
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: "60px",
|
||||
textAlign: "center",
|
||||
backgroundColor: isDisabled ? "#f0f0f0" : "white",
|
||||
color: "#000",
|
||||
cursor: isDisabled ? "not-allowed" : "text",
|
||||
opacity: isDisabled ? 0.6 : 1,
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const inputValue = e.target.value;
|
||||
if (
|
||||
inputValue === "" ||
|
||||
(!isNaN(inputValue) && Number(inputValue) >= 0)
|
||||
) {
|
||||
handleRealStockChange(
|
||||
row.productId,
|
||||
inputValue === "" ? 0 : Number(inputValue)
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: lang === "es" ? "AJUSTE" : "ADJUSTMENT",
|
||||
key: "adjustment",
|
||||
render: (value) => (
|
||||
<span
|
||||
style={{ color: value < 0 ? "red" : "inherit", fontWeight: "bold" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const filteredAdjustments = adjustments.filter(item =>
|
||||
item.product?.toLowerCase().includes(productSearch.toLowerCase())
|
||||
);
|
||||
|
||||
const exportColumns = [
|
||||
{
|
||||
header: lang === "es" ? "ID DEL PRODUCTO" : "PRODUCT ID",
|
||||
key: "productId",
|
||||
},
|
||||
{ header: lang === "es" ? "PRODUCTO" : "PRODUCT", key: "product" },
|
||||
{
|
||||
header: lang === "es" ? "STOCK ACTUAL" : "CURRENT STOCK",
|
||||
key: "currentStock",
|
||||
},
|
||||
{ header: lang === "es" ? "STOCK REAL" : "REAL STOCK", key: "realStock" },
|
||||
{ header: lang === "es" ? "AJUSTE" : "ADJUSTMENT", key: "adjustment" },
|
||||
];
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// Actualiza los ajustes antes de enviar
|
||||
const updatedAdjustments = adjustments.map((item) => ({
|
||||
...item,
|
||||
adjustment: item.realStock - item.currentStock,
|
||||
}));
|
||||
setAdjustments(updatedAdjustments);
|
||||
//console.log('Ajustes a guardar:', updatedAdjustments);
|
||||
|
||||
///***/// *//products/stockadjusmentset/${item.productId}`,
|
||||
|
||||
// {
|
||||
// "stockproductadjusment":[{"id_product":1,"stock_adjusment":2}]
|
||||
//
|
||||
// } valida positivo negativo
|
||||
// { quantity: (item.adjustment)*(-1), reasons: 'ajuste' }
|
||||
|
||||
// Petición a discardproduct
|
||||
for (const item of updatedAdjustments) {
|
||||
await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/products/stockadjusmentset`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
stockproductadjusment: [
|
||||
{
|
||||
id_product: item.productId,
|
||||
stock_adjusment: item.adjustment,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
alert(lang === "es" ? "Ajustes guardados correctamente" : "Adjustments saved successfully");
|
||||
redirect("/app/inventory");
|
||||
// Recarga la página para reflejar los cambios
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error saving adjustments:', error);
|
||||
alert(lang === "es" ? "Error al guardar los ajustes" : "Error saving adjustments");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="report-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">{lang === "es" ? "Ajustes" : "Adjustments"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="filters-section">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={lang === "es" ? "Search product..." : "Search product..."}
|
||||
value={productSearch}
|
||||
onChange={(e) => setProductSearch(e.target.value)}
|
||||
disabled={saving}
|
||||
className="filter-search"
|
||||
style={{
|
||||
backgroundColor: saving ? '#f5f5f5' : undefined,
|
||||
cursor: saving ? 'not-allowed' : undefined
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="save-button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving
|
||||
? lang === "es"
|
||||
? "Guardando..."
|
||||
: "Saving..."
|
||||
: lang === "es"
|
||||
? "Guardar"
|
||||
: "Save"
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem', position: 'relative' }}>
|
||||
<ExcelExportButton
|
||||
data={filteredAdjustments}
|
||||
columns={exportColumns}
|
||||
filenamePrefix={
|
||||
lang === "es" ? "ajustes-inventario" : "inventory-adjustments"
|
||||
}
|
||||
sheetName={lang === "es" ? "Ajustes" : "Adjustments"}
|
||||
/>
|
||||
{saving && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
cursor: 'not-allowed',
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="table-section">
|
||||
<Table columns={columns} data={filteredAdjustments} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
frontend/Frontend-Hotel/src/pages/Inventory/AlterProduct.css
Normal file
149
frontend/Frontend-Hotel/src/pages/Inventory/AlterProduct.css
Normal file
@@ -0,0 +1,149 @@
|
||||
.new-expense-container {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
min-height: 100vh;
|
||||
background-color: #f0f2f5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.form-id {
|
||||
font-size: 1.75rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0;
|
||||
color: #1a202c;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-grid > div, .image-upload-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input, select {
|
||||
padding: 0.8rem;
|
||||
border: 1px solid #cbd5e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: #3182ce;
|
||||
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.5);
|
||||
}
|
||||
|
||||
select[multiple] {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.save-button-wrapper {
|
||||
text-align: right;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background-color: #3182ce;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.8rem 1.6rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background-color: #2b6cb0;
|
||||
}
|
||||
|
||||
.save-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.save-button:disabled {
|
||||
background-color: #a0aec0;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.feedback-message {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feedback-message.success {
|
||||
background-color: #c6f6d5;
|
||||
color: #22543d;
|
||||
}
|
||||
|
||||
.feedback-message.error {
|
||||
background-color: #fed7d7;
|
||||
color: #742a2a;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background-color: #718096;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease, transform 0.1s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: #4a5568;
|
||||
}
|
||||
|
||||
.back-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
373
frontend/Frontend-Hotel/src/pages/Inventory/AlterProduct.jsx
Normal file
373
frontend/Frontend-Hotel/src/pages/Inventory/AlterProduct.jsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
import './AlterProduct.css';
|
||||
|
||||
const IMAGE_BASE_URL = import.meta.env.VITE_API_BASE_URL + '/products';
|
||||
|
||||
export default function NewProduct() {
|
||||
const { lang } = useContext(langContext);
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [productTypes, setProductTypes] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [suppliers, setSuppliers] = useState([]);
|
||||
const [currencies, setCurrencies] = useState([]);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [imagePreview, setImagePreview] = useState(null);
|
||||
const [form, setForm] = useState({
|
||||
up_name_product: '',
|
||||
up_sku_product: '',
|
||||
product_type: 1,
|
||||
id_category: 0,
|
||||
supplier_rfc: '',
|
||||
unit: '',
|
||||
new_stock: '',
|
||||
up_price_product: '',
|
||||
up_id_tax: '',
|
||||
img_product: '',
|
||||
currency: '',
|
||||
});
|
||||
|
||||
// Estados para la retroalimentación al usuario
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Efecto para cargar los datos de los selects y del producto a editar
|
||||
useEffect(() => {
|
||||
// Si no hay un ID en la URL, no se hace nada.
|
||||
if (!id) return;
|
||||
|
||||
const fetchInitialData = async () => {
|
||||
setLoading(true); // Mostrar indicador de carga mientras se obtienen los datos
|
||||
setError('');
|
||||
try {
|
||||
// Realizar todas las peticiones de red en paralelo para mayor eficiencia
|
||||
const [
|
||||
productTypesRes,
|
||||
categoriesRes,
|
||||
suppliersRes,
|
||||
currenciesRes,
|
||||
unitsRes,
|
||||
productRes
|
||||
] = await Promise.all([
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/products/producttype', { method: 'GET' }),
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/products/productcategory', { method: 'GET' }),
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/products/suppliers', { method: 'GET' }),
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/settings/currency', { method: 'GET' }),
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/settings/units', { method: 'GET' }),
|
||||
fetch(`${import.meta.env.VITE_API_BASE_URL}/products/product/${id}`, { method: 'PUT' })
|
||||
]);
|
||||
// Manejar errores si alguna de las respuestas de la API falla
|
||||
if (!productTypesRes.ok || !categoriesRes.ok || !suppliersRes.ok || !currenciesRes.ok || !unitsRes.ok || !productRes.ok) {
|
||||
throw new Error('Error al cargar los datos necesarios para el formulario.');
|
||||
}
|
||||
|
||||
// Convertir todas las respuestas a formato JSON
|
||||
const productTypesData = await productTypesRes.json();
|
||||
const categoriesData = await categoriesRes.json();
|
||||
const suppliersData = await suppliersRes.json();
|
||||
const currenciesData = await currenciesRes.json();
|
||||
const unitsData = await unitsRes.json();
|
||||
const productData = await productRes.json();
|
||||
|
||||
// Poblar los estados para las opciones de los selects
|
||||
setProductTypes(
|
||||
productTypesData.data.map(d => ({
|
||||
id: d.id_product_type,
|
||||
name: d.name_product_type,
|
||||
spanish_name: d.spanish_name
|
||||
}))
|
||||
);
|
||||
setCategories(
|
||||
categoriesData.data
|
||||
.filter(d => d.id_prod_category)
|
||||
.map(d => ({
|
||||
id: d.id_prod_category,
|
||||
name: d.name_prod_category,
|
||||
spanish_name: d.spanish_name
|
||||
}))
|
||||
);
|
||||
setSuppliers(suppliersData.data);
|
||||
if (currenciesData.currency) {
|
||||
setCurrencies(currenciesData.currency.map(c => ({ id: c.id_currency, name: c.name_currency })));
|
||||
}
|
||||
if (unitsData.units) {
|
||||
setUnits(unitsData.units.map(u => ({ id: u.id_unit, name: u.name_unit })));
|
||||
}
|
||||
|
||||
// Poblar el estado del formulario con los datos del producto existente
|
||||
if (productData && productData.data && productData.data.length > 0) {
|
||||
const prod = productData.data[0];
|
||||
setForm({
|
||||
up_name_product: prod.name_product || '',
|
||||
up_sku_product: prod.sku_product || '',
|
||||
product_type: prod.product_type || [], // Se espera que sea un array de IDs
|
||||
id_category: prod.id_category_pro || '',
|
||||
supplier_rfc: prod.supplier_product || '',
|
||||
unit: prod.id_unit || '',
|
||||
new_stock: prod.stock || '',
|
||||
up_price_product: prod.price_product || '',
|
||||
up_id_tax: prod.id_tax || '',
|
||||
img_product: prod.image_product || '',
|
||||
currency: prod.id_curreny || '',
|
||||
});
|
||||
|
||||
if (prod.image_product) {
|
||||
if (prod.image_product.startsWith('/')) {
|
||||
setImagePreview(`data:image/jpeg;base64,${prod.image_product}`);
|
||||
} else if (prod.image_product.length > 200) {
|
||||
setImagePreview(`data:image/jpeg;base64,${prod.image_product}`);
|
||||
} else {
|
||||
setImagePreview(`${IMAGE_BASE_URL}/${prod.image_product}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`No se encontró un producto con el ID ${id}.`);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error al cargar datos iniciales:", err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false); // Ocultar el indicador de carga
|
||||
}
|
||||
};
|
||||
|
||||
fetchInitialData();
|
||||
}, [id]); // El efecto se volverá a ejecutar si el `id` del producto cambia
|
||||
|
||||
// Manejar cambios en los campos del formulario
|
||||
const handleChange = e => {
|
||||
const { name, value } = e.target;
|
||||
// Manejo especial para el select múltiple de 'product_type'
|
||||
if (name === 'product_type') {
|
||||
const options = e.target.options;
|
||||
const selected = [];
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
if (options[i].selected) selected.push(Number(options[i].value));
|
||||
}
|
||||
setForm(prev => ({ ...prev, product_type: selected }));
|
||||
} else {
|
||||
setForm(prev => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
// Redimensionar y convertir la imagen a base64
|
||||
const handleImage = e => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const img = new window.Image();
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev => {
|
||||
img.onload = () => {
|
||||
const MAX_WIDTH = 800;
|
||||
const scale = Math.min(1, MAX_WIDTH / img.width);
|
||||
const width = img.width * scale;
|
||||
const height = img.height * scale;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
|
||||
const base64 = dataUrl.split(',')[1];
|
||||
setForm(prev => ({ ...prev, img_product: base64 }));
|
||||
setImagePreview(dataUrl);
|
||||
};
|
||||
img.src = ev.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Enviar el formulario para actualizar el producto
|
||||
const handleSubmit = async e => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
try {
|
||||
const payload = {
|
||||
up_name_product: form.up_name_product,
|
||||
up_sku_product: form.up_sku_product,
|
||||
product_type: form.product_type,
|
||||
id_category: form.id_category ? Number(form.id_category) : undefined,
|
||||
supplier_rfc: form.supplier_rfc,
|
||||
unit: form.unit ? Number(form.unit) : undefined,
|
||||
new_stock: form.new_stock ? Number(form.new_stock) : undefined,
|
||||
up_price_product: form.up_price_product ? Number(form.up_price_product) : undefined,
|
||||
up_id_tax: form.up_id_tax ? Number(form.up_id_tax) : undefined,
|
||||
img_product: form.img_product,
|
||||
currency: form.currency ? Number(form.currency) : undefined,
|
||||
};
|
||||
|
||||
// Limpiar campos vacíos o indefinidos antes de enviar
|
||||
Object.keys(payload).forEach(k => (payload[k] === '' || payload[k] === undefined) && delete payload[k]);
|
||||
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/products/update_product/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error('Error al actualizar el producto');
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="new-expense-container">
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<div className="form-id">{lang === "es" ? "Editar Producto" : "Edit Product"}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="back-button"
|
||||
onClick={() => navigate('/app/products')}
|
||||
>
|
||||
<span>←</span>
|
||||
{lang === "es" ? "Volver" : "Back"}
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{loading && !success && <p>{lang === "es" ? "Cargando datos del producto..." : "Loading product data..."}</p>}
|
||||
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
<label>{lang === "es" ? "Nombre del producto" : "Product Name"}</label>
|
||||
<input name="up_name_product" value={form.up_name_product} onChange={handleChange} required />
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "SKU" : "SKU"}</label>
|
||||
<input name="up_sku_product" value={form.up_sku_product} onChange={handleChange} type="number" required />
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Tipo de producto" : "Product Type"}</label>
|
||||
<select name="product_type" value={form.product_type} onChange={handleChange} required>
|
||||
<option value="">{lang === "es" ? "Selecciona categoría" : "Select category"}</option>
|
||||
{productTypes
|
||||
.sort((a, b) => {
|
||||
const nameA = (lang === "en" ? (a.name || '') : (a.spanish_name || a.name || '')).toLowerCase();
|
||||
const nameB = (lang === "en" ? (b.name || '') : (b.spanish_name || b.name || '')).toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map(pt => {
|
||||
const displayName = lang === "en" ? (pt.name || '') : (pt.spanish_name || pt.name || '');
|
||||
console.log(`AlterProduct - Product Type ${pt.id}: lang=${lang}, name=${pt.name}, spanish_name=${pt.spanish_name}, display=${displayName}`);
|
||||
return (
|
||||
<option key={pt.id} value={pt.id}>
|
||||
{displayName}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Categoría" : "Category"}</label>
|
||||
<select name="id_category" value={form.id_category} onChange={handleChange} required>
|
||||
<option selected="Hotel accesories">{lang === "es" ? "Selecciona categoría" : "Select category"}</option>
|
||||
{categories
|
||||
.sort((a, b) => {
|
||||
const nameA = (lang === "en" ? (a.name || '') : (a.spanish_name || a.name || '')).toLowerCase();
|
||||
const nameB = (lang === "en" ? (b.name || '') : (b.spanish_name || b.name || '')).toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map(cat => {
|
||||
const displayName = lang === "en" ? (cat.name || '') : (cat.spanish_name || cat.name || '');
|
||||
console.log(`AlterProduct - Category ${cat.id}: lang=${lang}, name=${cat.name}, spanish_name=${cat.spanish_name}, display=${displayName}`);
|
||||
return (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{displayName}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Proveedor" : "Supplier"}</label>
|
||||
<select name="supplier_rfc" value={form.supplier_rfc} onChange={handleChange} required>
|
||||
<option value="">{lang === "es" ? "Selecciona proveedor" : "Select supplier"}</option>
|
||||
{suppliers
|
||||
.sort((a, b) => {
|
||||
const nameA = (a.name_suppliers || '').toLowerCase();
|
||||
const nameB = (b.name_suppliers || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map(sup => (
|
||||
<option key={sup.rfc_suppliers} value={sup.rfc_suppliers}>{sup.name_suppliers}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Unidad" : "Unit"}</label>
|
||||
<select name="unit" value={form.unit} onChange={handleChange} required>
|
||||
<option value="">{lang === "es" ? "Selecciona unidad" : "Select unit"}</option>
|
||||
{units
|
||||
.sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase();
|
||||
const nameB = (b.name || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map(unit => (
|
||||
<option key={unit.id} value={unit.id}>{unit.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Stock" : "Stock"}</label>
|
||||
<input name="new_stock" value={form.new_stock} onChange={handleChange} type="number" required />
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Precio" : "Price"}</label>
|
||||
<input name="up_price_product" value={form.up_price_product} onChange={handleChange} type="number" step="0.01" required />
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Impuesto" : "Tax"}</label>
|
||||
<select name="up_id_tax" value={form.up_id_tax} onChange={handleChange} required>
|
||||
<option value="">{lang === "es" ? "Selecciona impuesto" : "Select tax"}</option>
|
||||
<option value="1">16%</option>
|
||||
<option value="2">8%</option>
|
||||
<option value="3">0%</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Moneda" : "Currency"}</label>
|
||||
<select name="currency" value={form.currency} onChange={handleChange} required>
|
||||
<option value="">{lang === "es" ? "Selecciona moneda" : "Select currency"}</option>
|
||||
{currencies
|
||||
.sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase();
|
||||
const nameB = (b.name || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map(cur => (
|
||||
<option key={cur.id} value={cur.id}>{cur.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="image-upload-section">
|
||||
<label>{lang === "es" ? "Imagen del producto" : "Product image"}</label>
|
||||
{imagePreview && (
|
||||
<div className="image-preview">
|
||||
<img src={imagePreview} alt="Product preview" style={{ maxWidth: '200px', maxHeight: '200px', marginBottom: '10px' }} />
|
||||
</div>
|
||||
)}
|
||||
<input type="file" accept="image/*" onChange={handleImage} />
|
||||
</div>
|
||||
<div className="save-button-wrapper">
|
||||
<button type="submit" className="save-button" disabled={loading}>{loading ? 'Actualizando...' : 'Actualizar producto'}</button>
|
||||
</div>
|
||||
{success && <div className="feedback-message success">{lang === "es" ? "¡Producto actualizado con éxito!" : "Product updated successfully!"}</div>}
|
||||
{error && <div className="feedback-message error">{error}</div>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
frontend/Frontend-Hotel/src/pages/Inventory/DiscardProduct.jsx
Normal file
304
frontend/Frontend-Hotel/src/pages/Inventory/DiscardProduct.jsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import "./NewProduct.css";
|
||||
import { useContext } from "react";
|
||||
import { langContext } from "../../context/LenguageContext";
|
||||
import DiscardConfirmModal from "../../components/Modals/DiscardConfirmModal";
|
||||
|
||||
export default function DiscardProduct() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [products, setProducts] = useState([]);
|
||||
console.log("PRODUCTS", products);
|
||||
const [form, setForm] = useState({
|
||||
productId: "",
|
||||
reasons: "",
|
||||
quantity: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selectedProduct, setSelectedProduct] = useState(null);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
|
||||
const loadProducts = useCallback(async () => {
|
||||
try {
|
||||
setError("");
|
||||
const response = await fetch(
|
||||
import.meta.env.VITE_API_BASE_URL + "/products"
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
lang === "es"
|
||||
? "Error al cargar los productos"
|
||||
: "Error loading products"
|
||||
);
|
||||
}
|
||||
const data = await response.json();
|
||||
setProducts(Array.isArray(data.data) ? data.data : []);
|
||||
} catch (err) {
|
||||
console.error("Error fetching products:", err);
|
||||
setError(
|
||||
err.message ||
|
||||
(lang === "es"
|
||||
? "Error al cargar los productos"
|
||||
: "Error loading products")
|
||||
);
|
||||
}
|
||||
}, [lang]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadInitialProducts() {
|
||||
try {
|
||||
setInitialLoading(true);
|
||||
await loadProducts();
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}
|
||||
loadInitialProducts();
|
||||
}, [loadProducts]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => {
|
||||
const updated = { ...prev, [name]: value };
|
||||
if (name === "productId") {
|
||||
const product = products.find((p) => p.id_product === parseInt(value));
|
||||
setSelectedProduct(product || null);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!form.productId) {
|
||||
setError(
|
||||
lang === "es"
|
||||
? "Por favor selecciona un producto"
|
||||
: "Please select a product"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const quantity = parseInt(form.quantity);
|
||||
if (!quantity || quantity < 1) {
|
||||
setError(
|
||||
lang === "es"
|
||||
? "La cantidad debe ser mayor a 0"
|
||||
: "Quantity must be greater than 0"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (selectedProduct && quantity > (parseInt(selectedProduct.units) || 0)) {
|
||||
setError(
|
||||
lang === "es"
|
||||
? `La cantidad no puede ser mayor al stock disponible (${selectedProduct.units})`
|
||||
: `Quantity cannot exceed available stock (${selectedProduct.units})`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!form.reasons || form.reasons.trim() === "") {
|
||||
setError(
|
||||
lang === "es" ? "Por favor ingresa una razón" : "Please enter a reason"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowConfirmModal(true);
|
||||
};
|
||||
|
||||
const handleConfirmDiscard = async () => {
|
||||
setShowConfirmModal(false);
|
||||
setLoading(true);
|
||||
const currentDate = new Date();
|
||||
try {
|
||||
const day = currentDate.getDate();
|
||||
const month = currentDate.getMonth() + 1;
|
||||
const year = currentDate.getFullYear();
|
||||
const formattedDate = `${day}-${month}-${year}`;
|
||||
|
||||
const payload = {
|
||||
quantity: parseInt(form.quantity),
|
||||
reasons: form.reasons.trim(),
|
||||
datediscard: formattedDate,
|
||||
};
|
||||
|
||||
const res = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/products/discardproduct/${form.productId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.message ||
|
||||
(lang === "es"
|
||||
? "Error al descartar el producto"
|
||||
: "Error discarding product")
|
||||
);
|
||||
}
|
||||
|
||||
await loadProducts();
|
||||
|
||||
setSuccess(true);
|
||||
setForm({ productId: "", reasons: "", quantity: "" });
|
||||
setSelectedProduct(null);
|
||||
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err.message ||
|
||||
(lang === "es"
|
||||
? "Error al descartar el producto"
|
||||
: "Error discarding product")
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDiscard = () => {
|
||||
setShowConfirmModal(false);
|
||||
};
|
||||
|
||||
const confirmMessage =
|
||||
lang === "es"
|
||||
? `¿Estás seguro de descartar ${form.quantity} unidad(es) de ${selectedProduct?.name_product || "este producto"}?`
|
||||
: `Are you sure you want to discard ${form.quantity} unit(s) of ${selectedProduct?.name_product || "this product"}?`;
|
||||
|
||||
return (
|
||||
<div className="new-expense-container">
|
||||
<div className="form-card">
|
||||
<div className="form-id">
|
||||
{lang === "es" ? "Descartar producto" : "Discard product"}
|
||||
</div>
|
||||
|
||||
{initialLoading ? (
|
||||
<p>
|
||||
{lang === "es" ? "Cargando productos..." : "Loading products..."}
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
<label>{lang === "es" ? "Producto" : "Product"}</label>
|
||||
<select
|
||||
name="productId"
|
||||
value={form.productId}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">
|
||||
{lang === "es" ? "Selecciona producto" : "Select product"}
|
||||
</option>
|
||||
{products
|
||||
.filter((prod) => (parseInt(prod.units) || 0) >= 1)
|
||||
.map((prod) => (
|
||||
<option key={prod.id_product} value={prod.id_product}>
|
||||
{prod.name_product}{" "}
|
||||
{prod.units !== undefined
|
||||
? `(${lang === "es" ? "Stock" : "Stock"}: ${prod.units})`
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Razón" : "Reason"}</label>
|
||||
<input
|
||||
name="reasons"
|
||||
value={form.reasons}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder={
|
||||
lang === "es"
|
||||
? "Ingresa la razón del descarte"
|
||||
: "Enter discard reason"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Cantidad" : "Quantity"}</label>
|
||||
<input
|
||||
name="quantity"
|
||||
value={form.quantity}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
min={1}
|
||||
max={selectedProduct ? selectedProduct.units : undefined}
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder={
|
||||
lang === "es"
|
||||
? "Cantidad a descartar"
|
||||
: "Quantity to discard"
|
||||
}
|
||||
/>
|
||||
{selectedProduct && (
|
||||
<small
|
||||
style={{
|
||||
color: "#666",
|
||||
fontSize: "0.85em",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
{lang === "es" ? "Stock disponible" : "Available stock"}:{" "}
|
||||
{selectedProduct.units || 0}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="save-button-wrapper">
|
||||
<button
|
||||
type="submit"
|
||||
className="save-button"
|
||||
disabled={loading || initialLoading}
|
||||
>
|
||||
{loading
|
||||
? lang === "es"
|
||||
? "Descartando..."
|
||||
: "Discarding..."
|
||||
: lang === "es"
|
||||
? "Descartar producto"
|
||||
: "Discard product"}
|
||||
</button>
|
||||
</div>
|
||||
{success && (
|
||||
<div className="feedback-message success">
|
||||
{lang === "es"
|
||||
? "¡Producto descartado con éxito!"
|
||||
: "Product discarded successfully!"}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="feedback-message error">{error}</div>}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DiscardConfirmModal
|
||||
isOpen={showConfirmModal}
|
||||
message={confirmMessage}
|
||||
onConfirm={handleConfirmDiscard}
|
||||
onCancel={handleCancelDiscard}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import '../../components/Filters/Filters.css';
|
||||
import './Outcomes.css';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
import ConfirmationOutcome from '../../components/Modals/ConfirmationOutcome';
|
||||
import axios from 'axios';
|
||||
|
||||
const IMAGE_BASE_URL = import.meta.env.VITE_API_BASE_URL + '/products';
|
||||
|
||||
|
||||
export default function HousekeeperOutcomes() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [products, setProducts] = useState([]);
|
||||
const [filteredProducts, setFilteredProducts] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [nameFilter, setNameFilter] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [stockFilter, setStockFilter] = useState('');
|
||||
const [formHousekepeer, setFormHousekepeer] = useState(null)
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [selectedProdName, setSelectedProdName] = useState(null);
|
||||
const [selectedProductStock, setSelectedProductStock] = useState(null);
|
||||
|
||||
const handleOpenModal = (id, product, productStock) => {
|
||||
setSelectedId(id);
|
||||
setSelectedProdName(product);
|
||||
setSelectedProductStock(productStock);
|
||||
//setSelectedStatus(statusType);
|
||||
//setSelectedStatusId(statusId);
|
||||
setModalOpen(true);
|
||||
};
|
||||
const handleConfirm = (PCO, UCO, HCO) => {
|
||||
if (!PCO || !UCO || !HCO) return;
|
||||
|
||||
const currentdate = new Date();
|
||||
axios.post(`${import.meta.env.VITE_API_BASE_URL}/products/newconsumptionstock`, {
|
||||
"product_id": PCO,
|
||||
"quantity_consumption": UCO,
|
||||
"date_consumption": currentdate.getFullYear() + "-" + (currentdate.getMonth() + 1) + "-" + currentdate.getDate(),
|
||||
"rfc_emp": HCO
|
||||
})
|
||||
.then((res) => {
|
||||
console.log(res || 'Outcome realizado correctamente');
|
||||
// Cerrar el modal y limpiar selección
|
||||
setModalOpen(false);
|
||||
setSelectedId(null);
|
||||
setSelectedProdName(null);
|
||||
setSelectedProductStock(null);
|
||||
async function loadProducts() {
|
||||
try {
|
||||
const response = await fetch(import.meta.env.VITE_API_BASE_URL + '/products');
|
||||
const data = await response.json();
|
||||
const productsList = Array.isArray(data.data) ? data.data : [];
|
||||
|
||||
const productsWithDetails = await Promise.all(
|
||||
productsList.map(async (prod) => {
|
||||
try {
|
||||
const detailResponse = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/products/product/${prod.id_product}`,
|
||||
{ method: "PUT" }
|
||||
);
|
||||
const detailData = await detailResponse.json();
|
||||
if (detailData.data && detailData.data.length > 0) {
|
||||
const detail = detailData.data[0];
|
||||
return {
|
||||
...prod,
|
||||
category: detail.id_category_pro || null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prod,
|
||||
category: null,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Error fetching product ${prod.id_product}:`, err);
|
||||
return {
|
||||
...prod,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setProducts(productsWithDetails);
|
||||
setFilteredProducts(productsWithDetails);
|
||||
} catch (err) {
|
||||
console.error('Error fetching products', err);
|
||||
setProducts([]);
|
||||
setFilteredProducts([]);
|
||||
}
|
||||
}
|
||||
loadProducts();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Error al actualizar el outcome con el producto ${selectedId}:`, err);
|
||||
alert('❌ Error al actualizar el outcome.');
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function loadProducts() {
|
||||
try {
|
||||
const response = await fetch(import.meta.env.VITE_API_BASE_URL + '/products');
|
||||
const data = await response.json();
|
||||
const productsList = Array.isArray(data.data) ? data.data : [];
|
||||
|
||||
const productsWithDetails = await Promise.all(
|
||||
productsList.map(async (prod) => {
|
||||
try {
|
||||
const detailResponse = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/products/product/${prod.id_product}`,
|
||||
{ method: "PUT" }
|
||||
);
|
||||
const detailData = await detailResponse.json();
|
||||
if (detailData.data && detailData.data.length > 0) {
|
||||
const detail = detailData.data[0];
|
||||
return {
|
||||
...prod,
|
||||
category: detail.id_category_pro || null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prod,
|
||||
category: null,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Error fetching product ${prod.id_product}:`, err);
|
||||
return {
|
||||
...prod,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setProducts(productsWithDetails);
|
||||
setFilteredProducts(productsWithDetails);
|
||||
} catch (err) {
|
||||
console.error('Error fetching products', err);
|
||||
setProducts([]);
|
||||
setFilteredProducts([]);
|
||||
}
|
||||
}
|
||||
async function fetchSelectData() {
|
||||
try {
|
||||
const res = await fetch(import.meta.env.VITE_API_BASE_URL + '/products/gethousekeeper', {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (!res.ok) throw new Error(`Error fetching info: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setFormHousekepeer(data.houseKeeper || []);
|
||||
} catch (err) {
|
||||
console.error('Error cargando metadata (getinfo):', err);
|
||||
}
|
||||
}
|
||||
fetchSelectData();
|
||||
loadProducts();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadFilterOptions() {
|
||||
try {
|
||||
const categoriesRes = await fetch(
|
||||
import.meta.env.VITE_API_BASE_URL + "/products/productcategory"
|
||||
);
|
||||
|
||||
const categoriesData = await categoriesRes.json();
|
||||
|
||||
if (categoriesData.data) {
|
||||
setCategories(
|
||||
categoriesData.data
|
||||
.filter((d) => d.id_prod_category)
|
||||
.map((d) => ({
|
||||
id: d.id_prod_category,
|
||||
name: d.name_prod_category,
|
||||
spanish_name: d.spanish_name || d.name_prod_category,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error loading filter options", err);
|
||||
}
|
||||
}
|
||||
loadFilterOptions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let filtered = [...products];
|
||||
|
||||
if (nameFilter) {
|
||||
filtered = filtered.filter(prod =>
|
||||
prod.name_product?.toLowerCase().includes(nameFilter.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (categoryFilter) {
|
||||
filtered = filtered.filter(
|
||||
(prod) => prod.category && prod.category.toString() === categoryFilter
|
||||
);
|
||||
}
|
||||
|
||||
if (stockFilter) {
|
||||
switch (stockFilter) {
|
||||
case 'out':
|
||||
filtered = filtered.filter(prod => (parseInt(prod.units) || 0) === 0);
|
||||
break;
|
||||
case 'low':
|
||||
filtered = filtered.filter(prod => {
|
||||
const units = parseInt(prod.units) || 0;
|
||||
return units > 0 && units < 10;
|
||||
});
|
||||
break;
|
||||
case 'in':
|
||||
filtered = filtered.filter(prod => (parseInt(prod.units) || 0) >= 10);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setFilteredProducts(filtered);
|
||||
}, [nameFilter, categoryFilter, stockFilter, products]);
|
||||
|
||||
return (
|
||||
<div className="outcomes-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">{lang === "es" ? "Productos" : "Products"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="filters-section">
|
||||
<input
|
||||
type="text"
|
||||
className="filter-search"
|
||||
placeholder={lang === 'es' ? 'Search by name...' : 'Search by name...'}
|
||||
value={nameFilter}
|
||||
onChange={(e) => setNameFilter(e.target.value)}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">
|
||||
{lang === "es" ? "Todas las categorías" : "All Categories"}
|
||||
</option>
|
||||
{categories
|
||||
.sort((a, b) => {
|
||||
const nameA = lang === "en" ? a.name : a.spanish_name;
|
||||
const nameB = lang === "en" ? b.name : b.spanish_name;
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map((cat, index) => (
|
||||
<option key={index} value={cat.id}>
|
||||
{lang === "en" ? cat.name : cat.spanish_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={stockFilter}
|
||||
onChange={(e) => setStockFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "es" ? "Todo el stock" : "All Stock"}</option>
|
||||
<option value="out">{lang === "es" ? "Sin stock" : "Out of Stock"}</option>
|
||||
<option value="low">{lang === "es" ? "Stock bajo (< 10)" : "Low Stock (< 10)"}</option>
|
||||
<option value="in">{lang === "es" ? "En stock (≥ 10)" : "In Stock (≥ 10)"}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="products-grid">
|
||||
{Array.isArray(filteredProducts) &&
|
||||
filteredProducts.map((prod, index) => (
|
||||
<div key={prod.id_product || index} className="product-card" onClick={() => handleOpenModal(prod.id_product, prod.name_product, prod.units)}>
|
||||
{prod.image_product && (
|
||||
<img
|
||||
src={
|
||||
prod.image_product.startsWith('http')
|
||||
? prod.image_product
|
||||
: prod.image_product.length > 200
|
||||
? `data:image/jpeg;base64,${prod.image_product}`
|
||||
: `${IMAGE_BASE_URL}/${prod.image_product}`
|
||||
}
|
||||
alt={prod.name_product}
|
||||
className="product-image"
|
||||
/>
|
||||
)}
|
||||
<div className="product-info">
|
||||
<div className="product-name">{prod.name_product}</div>
|
||||
<div className="product-price">{lang === "es" ? "Precio: $" : "Price: $"}{prod.price_product}</div>
|
||||
<div className="product-units">{lang === "es" ? "Unidades: " : "Units: "}{prod.units}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ConfirmationOutcome
|
||||
isOpen={modalOpen}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
formHousekepeer={formHousekepeer}
|
||||
idproduct={selectedId}
|
||||
nameProduct={selectedProdName}
|
||||
productStock={selectedProductStock}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
frontend/Frontend-Hotel/src/pages/Inventory/InventoryReport.css
Normal file
161
frontend/Frontend-Hotel/src/pages/Inventory/InventoryReport.css
Normal file
@@ -0,0 +1,161 @@
|
||||
.report-page {
|
||||
padding: 20px;
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.report-page h2 {
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.page-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-filters select {
|
||||
color: #213547;
|
||||
background-color: white;
|
||||
max-width: 250px;
|
||||
min-width: 150px;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.page-filters input[type="date"] {
|
||||
color: #213547;
|
||||
background-color: white;
|
||||
max-width: 200px;
|
||||
min-width: 150px;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.summary-container {
|
||||
display: flex;
|
||||
gap: 80px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
padding: 15px 25px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
min-width: 180px;
|
||||
text-align: center;
|
||||
color: #213547;
|
||||
align-items: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
color: #213547;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.summary-card div {
|
||||
color: #213547;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.inventory-filters {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.inventory-filters .filter-search {
|
||||
flex: 1;
|
||||
max-width: 280px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.inventory-filters .date-input-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.inventory-filters .date-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.inventory-filters .date-input {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inventory-filters .date-input:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcd200;
|
||||
}
|
||||
|
||||
.inventory-filters .date-input:hover {
|
||||
box-shadow: 0 0 0 2px #e6e6e6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.inventory-filters {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.inventory-filters .filter-search {
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.inventory-filters .filter-select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.inventory-filters .date-input-group {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inventory-filters .date-input {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.inventory-filters .clear-filters-btn {
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
351
frontend/Frontend-Hotel/src/pages/Inventory/InventoryReport.jsx
Normal file
351
frontend/Frontend-Hotel/src/pages/Inventory/InventoryReport.jsx
Normal file
@@ -0,0 +1,351 @@
|
||||
import React, { useEffect, useState, useContext } from 'react';
|
||||
import Table from '../../components/Table/HotelTable';
|
||||
import ConfirmationModal from '../../components/Modals/ConfirmationModal';
|
||||
import '../../components/Filters/Filters.css';
|
||||
import './InventoryReport.css';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
import ExcelExportButton from '../../components/ExcelExportButton';
|
||||
|
||||
|
||||
export default function InventoryReport() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [data, setData] = useState([]);
|
||||
const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
const [productFilter, setProductFilter] = useState('');
|
||||
const [productSelectFilter, setProductSelectFilter] = useState('');
|
||||
const [movementFilter, setMovementFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/products/reportinventory')
|
||||
.then(res => res.json())
|
||||
.then(report => {
|
||||
if (report && report.new_product) {
|
||||
const products = report.new_product.map(item => ({
|
||||
dateOriginal: item.date_movement ? new Date(item.date_movement) : null,
|
||||
productId: item.id_product,
|
||||
product: item.name_product,
|
||||
movement: item.movement,
|
||||
quantity: item.quantity,
|
||||
beforeStock: Number(item.before_stock),
|
||||
stock: Number(item.stock),
|
||||
commentsDiscard: item.comments_discard,
|
||||
housekeeperName: item.housekeeper_name
|
||||
}));
|
||||
setData(products);
|
||||
} else {
|
||||
setData([]);
|
||||
}
|
||||
})
|
||||
.catch(() => setData([]));
|
||||
}, []);
|
||||
|
||||
const uniqueProducts = [...new Set(data.map(item => item.product).filter(Boolean))].sort();
|
||||
const uniqueMovements = [...new Set(data.map(item => item.movement).filter(Boolean))].sort();
|
||||
|
||||
const filteredData = data.filter((item) => {
|
||||
if (dateRange.from && dateRange.to) {
|
||||
if (!item.dateOriginal) return false;
|
||||
const movementDate = new Date(item.dateOriginal);
|
||||
const from = new Date(dateRange.from);
|
||||
const to = new Date(dateRange.to);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
if (movementDate < from || movementDate > to) return false;
|
||||
}
|
||||
|
||||
if (productFilter && !item.product?.toLowerCase().includes(productFilter.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (productSelectFilter && item.product !== productSelectFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (movementFilter && item.movement !== movementFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalMovements = filteredData.length;
|
||||
const stockSum = filteredData.reduce((acc, curr) => acc + (curr.stock || 0), 0);
|
||||
|
||||
const clearFilters = () => {
|
||||
setDateRange({ from: '', to: '' });
|
||||
setProductFilter('');
|
||||
setProductSelectFilter('');
|
||||
setMovementFilter('');
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ header: lang === "es" ? 'ID Producto' : 'Product ID', key: 'productId' },
|
||||
{ header: lang === "es" ? 'Producto' : 'Product', key: 'product' },
|
||||
{ header: lang === "es" ? 'Movimiento' : 'Movement', key: 'movement' },
|
||||
{
|
||||
header: lang === "es" ? 'Comentarios' : 'Comments',
|
||||
key: 'commentsDiscard',
|
||||
render: (text, row) => {
|
||||
const movement = row.movement?.toLowerCase();
|
||||
if (movement === 'discard' && row.commentsDiscard) {
|
||||
return row.commentsDiscard;
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
},
|
||||
{
|
||||
header: lang === "es" ? 'Ama de Llaves' : 'Housekeeper',
|
||||
key: 'housekeeperName',
|
||||
render: (text, row) => {
|
||||
const movement = row.movement?.toLowerCase();
|
||||
if (movement === 'consumption' && row.housekeeperName) {
|
||||
return row.housekeeperName;
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
},
|
||||
{ header: lang === "es" ? 'Cantidad' : 'Quantity', key: 'quantity' },
|
||||
{
|
||||
header: lang === "es" ? 'Stock Anterior' : 'Before Stock',
|
||||
key: 'beforeStock',
|
||||
render: (value) => value !== undefined && value !== null ? value : '—'
|
||||
},
|
||||
{ header: lang === "es" ? 'Stock' : 'Stock', key: 'stock' },
|
||||
{
|
||||
header: lang === "es" ? 'Fecha' : 'Date',
|
||||
key: 'dateOriginal',
|
||||
render: (text, row) => {
|
||||
const date = row.dateOriginal;
|
||||
return date && !isNaN(date.getTime())
|
||||
? date.toLocaleDateString(lang === "es" ? "es-MX" : "en-US")
|
||||
: '—';
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
const exportColumns = [
|
||||
{ header: lang === "es" ? 'ID Producto' : 'Product ID', key: 'productId' },
|
||||
{ header: lang === "es" ? 'Producto' : 'Product', key: 'product' },
|
||||
{ header: lang === "es" ? 'Movimiento' : 'Movement', key: 'movement' },
|
||||
{ header: lang === "es" ? 'Comentarios' : 'Comments', key: 'commentsDiscard' },
|
||||
{ header: lang === "es" ? 'Ama de Llaves' : 'Housekeeper', key: 'housekeeperName' },
|
||||
{ header: lang === "es" ? 'Cantidad' : 'Quantity', key: 'quantity' },
|
||||
{ header: lang === "es" ? 'Stock Anterior' : 'Before Stock', key: 'beforeStock' },
|
||||
{ header: lang === "es" ? 'Stock' : 'Stock', key: 'stock' },
|
||||
{ header: lang === "es" ? 'Fecha' : 'Date', key: 'dateOriginal' },
|
||||
];
|
||||
|
||||
const transformDataForExport = (data) => {
|
||||
return data.map(item => {
|
||||
const movement = item.movement?.toLowerCase();
|
||||
return {
|
||||
...item,
|
||||
dateOriginal: item.dateOriginal && !isNaN(item.dateOriginal.getTime())
|
||||
? item.dateOriginal.toLocaleDateString(lang === "es" ? "es-MX" : "en-US")
|
||||
: '—',
|
||||
comments_discard: movement === 'discard' && item.comments_discard ? item.comments_discard : '—',
|
||||
housekeeper_name: movement === 'consumption' && item.housekeeper_name ? item.housekeeper_name : '—'
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="report-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">{lang === "es" ? "Reporte de Inventario" : "Inventory Report"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="inventory-filters">
|
||||
<div className="date-input-group">
|
||||
<label className="date-label">
|
||||
{lang === "es" ? "Desde:" : "From:"}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.from}
|
||||
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
|
||||
className="date-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="date-input-group">
|
||||
<label className="date-label">
|
||||
{lang === "es" ? "Hasta:" : "To:"}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.to}
|
||||
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
className="date-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder={lang === "es" ? "Search product..." : "Search product..."}
|
||||
value={productFilter}
|
||||
onChange={(e) => setProductFilter(e.target.value)}
|
||||
className="filter-search"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={productSelectFilter}
|
||||
onChange={(e) => setProductSelectFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "es" ? "Todos los productos" : "All Products"}</option>
|
||||
{uniqueProducts.map((product, index) => (
|
||||
<option key={index} value={product}>
|
||||
{product}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={movementFilter}
|
||||
onChange={(e) => setMovementFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "es" ? "Todos los movimientos" : "All Movements"}</option>
|
||||
{uniqueMovements.map((movement, index) => (
|
||||
<option key={index} value={movement}>
|
||||
{movement}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="clear-filters-btn"
|
||||
>
|
||||
{lang === 'es' ? 'Limpiar filtros' : 'Clear filters'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="summary-container" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', gap: '80px' }}>
|
||||
<div className="summary-card">
|
||||
<strong>{lang === "es" ? "Total Movimientos" : "Total Movements"}</strong>
|
||||
<div>{totalMovements}</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<strong>{lang === "es" ? "Stock Total" : "Total Stock"}</strong>
|
||||
<div>{stockSum}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ExcelExportButton
|
||||
data={filteredData}
|
||||
columns={exportColumns}
|
||||
filenamePrefix={lang === "es" ? "reporte-inventario" : "inventory-report"}
|
||||
sheetName={lang === "es" ? "Reporte" : "Report"}
|
||||
dataTransform={transformDataForExport}
|
||||
className="export-button"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="table-section">
|
||||
<Table columns={columns} data={filteredData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// import React, { useEffect, useState } from 'react';
|
||||
// import Table from '../../components/Table/HotelTable';
|
||||
// import ConfirmationModal from '../../components/Modals/ConfirmationModal';
|
||||
// import '../../components/Filters/Filters.css';
|
||||
|
||||
// const fakeInventoryReport = [
|
||||
// {
|
||||
// id: 1,
|
||||
// date: '2025-09-10',
|
||||
// productId: 'P-001',
|
||||
// product: 'Coffee Beans',
|
||||
// movement: 'Stock In',
|
||||
// quantity: 100,
|
||||
// stock: 350
|
||||
// },
|
||||
// {
|
||||
// id: 2,
|
||||
// date: '2025-09-12',
|
||||
// productId: 'P-002',
|
||||
// product: 'Sugar',
|
||||
// movement: 'Stock Out',
|
||||
// quantity: 20,
|
||||
// stock: 80
|
||||
// },
|
||||
// {
|
||||
// id: 3,
|
||||
// date: '2025-09-13',
|
||||
// productId: 'P-003',
|
||||
// product: 'Milk',
|
||||
// movement: 'Stock In',
|
||||
// quantity: 50,
|
||||
// stock: 150
|
||||
// },
|
||||
// ];
|
||||
|
||||
// export default function InventoryReport() {
|
||||
// const [data, setData] = useState([]);
|
||||
// const [dateRange, setDateRange] = useState({ from: '', to: '' });
|
||||
|
||||
// useEffect(() => {
|
||||
// setData(fakeInventoryReport);
|
||||
// // En el futuro: aquí llamas a tu API real con fetch/axios
|
||||
// }, []);
|
||||
|
||||
// const filteredData = data.filter((item) => {
|
||||
// if (!dateRange.from || !dateRange.to) return true;
|
||||
// const movementDate = new Date(item.date);
|
||||
// const from = new Date(dateRange.from);
|
||||
// const to = new Date(dateRange.to);
|
||||
// return movementDate >= from && movementDate <= to;
|
||||
// });
|
||||
|
||||
// const totalMovements = filteredData.length;
|
||||
|
||||
// const stockSum = filteredData.reduce((acc, curr) => acc + curr.stock, 0);
|
||||
|
||||
// const columns = [
|
||||
// { header: 'DATE', key: 'date' },
|
||||
// { header: 'PRODUCT ID', key: 'productId' },
|
||||
// { header: 'PRODUCT', key: 'product' },
|
||||
// { header: 'MOVEMENT', key: 'movement' },
|
||||
// { header: 'QUANTITY', key: 'quantity' },
|
||||
// { header: 'STOCK', key: 'stock' },
|
||||
// ];
|
||||
|
||||
// return (
|
||||
// <div className="report-page">
|
||||
// <h2>REPORT</h2>
|
||||
|
||||
// {/* Filtros por fecha */}
|
||||
// <div className="page-filters">
|
||||
// <input
|
||||
// type="date"
|
||||
// value={dateRange.from}
|
||||
// onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
|
||||
// />
|
||||
// <input
|
||||
// type="date"
|
||||
// value={dateRange.to}
|
||||
// onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
// />
|
||||
// </div>
|
||||
|
||||
// {/* Summary */}
|
||||
// <div className="summary-container">
|
||||
// <div className="summary-card">
|
||||
// <strong>Total Movements</strong>
|
||||
// <div>{totalMovements}</div>
|
||||
// </div>
|
||||
// <div className="summary-card">
|
||||
// <strong>Total Stock</strong>
|
||||
// <div>{stockSum}</div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <Table columns={columns} data={filteredData} />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
259
frontend/Frontend-Hotel/src/pages/Inventory/NewProduct.css
Normal file
259
frontend/Frontend-Hotel/src/pages/Inventory/NewProduct.css
Normal file
@@ -0,0 +1,259 @@
|
||||
/* .new-product-container {
|
||||
padding: 20px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.new-product-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.new-product-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.new-product-form input,
|
||||
.new-product-form select {
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.new-product-form button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-product-form button:hover {
|
||||
background-color: #0056b3;
|
||||
} */
|
||||
|
||||
|
||||
.new-expense-container {
|
||||
color: #213547;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.form-id {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0;
|
||||
font-size: 24px;
|
||||
color: #213547;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-grid > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-grid label {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.form-grid input,
|
||||
.form-grid select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background-color: #f9f9f9;
|
||||
color: #213547;
|
||||
box-sizing: border-box;
|
||||
height: 38px;
|
||||
min-width: 0; /* Prevent inputs from overflowing grid cells */
|
||||
}
|
||||
|
||||
.form-grid input[type="file"] {
|
||||
padding: 6px 4px;
|
||||
height: auto;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-grid input:focus,
|
||||
.form-grid select:focus {
|
||||
outline: none;
|
||||
border-color: #ffcb05;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
form > div:not(.form-grid):not(.save-button-wrapper) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
form > div:not(.form-grid):not(.save-button-wrapper) input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background-color: #f9f9f9;
|
||||
color: #213547;
|
||||
box-sizing: border-box;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
form > div:not(.form-grid):not(.save-button-wrapper) input[type="file"] {
|
||||
padding: 6px 4px;
|
||||
height: auto;
|
||||
background-color: transparent;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
form > div:not(.form-grid):not(.save-button-wrapper) label {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.save-button-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background-color: #ffcb05;
|
||||
color: #fff;
|
||||
padding: 10px 30px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background-color: #f4b400;
|
||||
}
|
||||
|
||||
.save-button:disabled {
|
||||
background-color: #a0aec0;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background-color: #718096;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease, transform 0.1s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: #4a5568;
|
||||
}
|
||||
|
||||
.back-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.feedback-message {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feedback-message.success {
|
||||
background-color: #c6f6d5;
|
||||
color: #22543d;
|
||||
}
|
||||
|
||||
.feedback-message.error {
|
||||
background-color: #fed7d7;
|
||||
color: #742a2a;
|
||||
}
|
||||
|
||||
.product-table-section {
|
||||
margin-top: 25px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.product-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.product-table th, .product-table td {
|
||||
border: 1px solid #d4af37; /* dorado */
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.product-table th {
|
||||
background-color: #800020; /* bordo/morado oscuro, según tus imágenes */
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.add-product-row {
|
||||
cursor: pointer;
|
||||
color: #d4af37; /* dorado */
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tax-pill {
|
||||
background-color: white;
|
||||
border: 1px solid #d4af37;
|
||||
border-radius: 12px;
|
||||
padding: 2px 6px;
|
||||
color: #800020;
|
||||
}
|
||||
|
||||
.product-table tfoot td {
|
||||
border-top: 2px solid #d4af37;
|
||||
padding: 8px;
|
||||
}
|
||||
415
frontend/Frontend-Hotel/src/pages/Inventory/NewProduct.jsx
Normal file
415
frontend/Frontend-Hotel/src/pages/Inventory/NewProduct.jsx
Normal file
@@ -0,0 +1,415 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import './NewProduct.css';
|
||||
import { useContext } from 'react';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
|
||||
export default function NewProduct() {
|
||||
const { lang } = useContext(langContext);
|
||||
const navigate = useNavigate();
|
||||
const [productTypes, setProductTypes] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [suppliers, setSuppliers] = useState([]);
|
||||
const [form, setForm] = useState({
|
||||
new_name_product: '',
|
||||
new_sku_product: '',
|
||||
product_type: '',
|
||||
new_category: '',
|
||||
new_rfc_suppliers: '',
|
||||
unit: '',
|
||||
new_stock: '',
|
||||
newprice_product: '',
|
||||
new_tax: '',
|
||||
currency: '', // aquí se guardará el id_currency
|
||||
image_product: '',
|
||||
});
|
||||
const [currencies, setCurrencies] = useState([]);
|
||||
const [_imageFile, setImageFile] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [units, setUnits] = useState([]);
|
||||
const [packageQuantity, setPackageQuantity] = useState('');
|
||||
const [piecesPerPackage, setPiecesPerPackage] = useState('');
|
||||
const [numberOfPairs, setNumberOfPairs] = useState('');
|
||||
const [numberOfDozens, setNumberOfDozens] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/products/producttype')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setProductTypes(
|
||||
data.data.map(d => ({
|
||||
id: d.id_product_type,
|
||||
name: d.name_product_type,
|
||||
spanish_name: d.spanish_name || d.name_product_type
|
||||
}))
|
||||
);
|
||||
});
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/products/productcategory')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setCategories(
|
||||
data.data
|
||||
.filter(d => d.id_prod_category)
|
||||
.map(d => ({
|
||||
id: d.id_prod_category,
|
||||
name: d.name_prod_category,
|
||||
spanish_name: d.spanish_name || d.name_prod_category
|
||||
}))
|
||||
);
|
||||
});
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/products/suppliers')
|
||||
.then(res => res.json())
|
||||
.then(data => setSuppliers(data.data));
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/settings/currency')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.currency) {
|
||||
setCurrencies(data.currency.map(c => ({ id: c.id_currency, name: c.name_currency })));
|
||||
}
|
||||
});
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/settings/units')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.units) {
|
||||
setUnits(data.units.map(u => ({
|
||||
id: u.id_unit,
|
||||
name: u.name_unit,
|
||||
spanish_name: u.spanish_name || u.name_unit
|
||||
})));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
const getSelectedUnitName = () => {
|
||||
if (!form.unit) return '';
|
||||
const selectedUnit = units.find(u => u.id === Number(form.unit));
|
||||
return selectedUnit ? selectedUnit.name.toLowerCase() : '';
|
||||
};
|
||||
|
||||
const getUnitType = () => {
|
||||
const unitName = getSelectedUnitName();
|
||||
if (unitName.includes('package') || unitName.includes('paquete')) {
|
||||
return 'package';
|
||||
}
|
||||
if (unitName.includes('pair') || unitName.includes('par')) {
|
||||
return 'pair';
|
||||
}
|
||||
if (unitName.includes('dozen') || unitName.includes('docena')) {
|
||||
return 'dozen';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.unit) return;
|
||||
|
||||
const selectedUnit = units.find(u => u.id === Number(form.unit));
|
||||
if (!selectedUnit) return;
|
||||
|
||||
const unitName = selectedUnit.name.toLowerCase();
|
||||
let calculatedStock = '';
|
||||
|
||||
if (unitName.includes('package') || unitName.includes('paquete')) {
|
||||
if (packageQuantity && piecesPerPackage) {
|
||||
calculatedStock = (Number(packageQuantity) * Number(piecesPerPackage)).toString();
|
||||
}
|
||||
} else if (unitName.includes('pair') || unitName.includes('par')) {
|
||||
if (numberOfPairs) {
|
||||
calculatedStock = (Number(numberOfPairs) * 2).toString();
|
||||
}
|
||||
} else if (unitName.includes('dozen') || unitName.includes('docena')) {
|
||||
if (numberOfDozens) {
|
||||
calculatedStock = (Number(numberOfDozens) * 12).toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (calculatedStock) {
|
||||
setForm(prev => ({ ...prev, new_stock: calculatedStock }));
|
||||
}
|
||||
}, [packageQuantity, piecesPerPackage, numberOfPairs, numberOfDozens, form.unit, units]);
|
||||
|
||||
// Manejar cambios en el formulario
|
||||
const handleChange = e => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name === 'unit') {
|
||||
const newUnitType = units.find(u => u.id === Number(value));
|
||||
const newUnitName = newUnitType ? newUnitType.name.toLowerCase() : '';
|
||||
|
||||
const isPackage = newUnitName.includes('package') || newUnitName.includes('paquete');
|
||||
const isPair = newUnitName.includes('pair') || newUnitName.includes('par');
|
||||
const isDozen = newUnitName.includes('dozen') || newUnitName.includes('docena');
|
||||
|
||||
if (!isPackage) {
|
||||
setPackageQuantity('');
|
||||
setPiecesPerPackage('');
|
||||
}
|
||||
if (!isPair) {
|
||||
setNumberOfPairs('');
|
||||
}
|
||||
if (!isDozen) {
|
||||
setNumberOfDozens('');
|
||||
}
|
||||
|
||||
if (!isPackage && !isPair && !isDozen) {
|
||||
setForm(prev => ({ ...prev, [name]: value, new_stock: '' }));
|
||||
} else {
|
||||
setForm(prev => ({ ...prev, [name]: value }));
|
||||
}
|
||||
} else {
|
||||
setForm(prev => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
// Manejar imagen
|
||||
|
||||
// Redimensionar imagen antes de convertir a base64
|
||||
const handleImage = e => {
|
||||
const file = e.target.files[0];
|
||||
setImageFile(file);
|
||||
if (file) {
|
||||
const img = new window.Image();
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev => {
|
||||
img.onload = () => {
|
||||
// Redimensionar a 800px de ancho máximo
|
||||
const MAX_WIDTH = 800;
|
||||
const scale = Math.min(1, MAX_WIDTH / img.width);
|
||||
const width = img.width * scale;
|
||||
const height = img.height * scale;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
// Convertir a base64 (jpeg para mayor compresión)
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.8); // calidad 80%
|
||||
const base64 = dataUrl.split(',')[1];
|
||||
setForm(prev => ({ ...prev, image_product: base64 }));
|
||||
};
|
||||
img.src = ev.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Enviar formulario
|
||||
const handleSubmit = async e => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
try {
|
||||
// Construir payload
|
||||
const payload = {
|
||||
...form,
|
||||
product_type: form.product_type ? [Number(form.product_type)] : [],
|
||||
new_category: form.new_category ? Number(form.new_category) : undefined,
|
||||
new_rfc_suppliers: form.new_rfc_suppliers,
|
||||
new_sku_product: form.new_sku_product ? Number(form.new_sku_product) : undefined,
|
||||
unit: form.unit ? Number(form.unit) : undefined,
|
||||
new_stock: form.new_stock ? Number(form.new_stock) : undefined,
|
||||
newprice_product: form.newprice_product ? Number(form.newprice_product) : undefined,
|
||||
new_tax: form.new_tax ? Number(form.new_tax) : undefined,
|
||||
currency: form.currency ? Number(form.currency) : undefined, // id_currency como número
|
||||
};
|
||||
// Eliminar campos vacíos
|
||||
Object.keys(payload).forEach(k => (payload[k] === '' || payload[k] === undefined) && delete payload[k]);
|
||||
const res = await fetch(import.meta.env.VITE_API_BASE_URL + '/products/newproduct', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error('Error al guardar el producto');
|
||||
setSuccess(true);
|
||||
setForm({
|
||||
new_name_product: '', new_sku_product: '', product_type: '', new_category: '', new_rfc_suppliers: '', unit: '', new_stock: '', newprice_product: '', new_tax: '', currency: '', image_product: ''
|
||||
});
|
||||
setImageFile(null);
|
||||
setPackageQuantity('');
|
||||
setPiecesPerPackage('');
|
||||
setNumberOfPairs('');
|
||||
setNumberOfDozens('');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="new-expense-container">
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<div className="form-id">{lang === "es" ? "Nuevo producto" : "New product"}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="back-button"
|
||||
onClick={() => navigate('/app/products')}
|
||||
>
|
||||
← {lang === "es" ? "Volver" : "Back"}
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
<label>{lang === "es" ? "Nombre del producto" : "Product Name"}</label>
|
||||
<input name="new_name_product" value={form.new_name_product} onChange={handleChange} required />
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "SKU" : "SKU"}</label>
|
||||
<input name="new_sku_product" value={form.new_sku_product} onChange={handleChange} type="number" required />
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Tipo de producto" : "Product Type"}</label>
|
||||
<select name="product_type" value={form.product_type} onChange={handleChange} required>
|
||||
<option value="">{lang === "es" ? "Selecciona tipo" : "Select type"}</option>
|
||||
{productTypes
|
||||
.sort((a, b) => {
|
||||
const nameA = lang === "en" ? a.name : a.spanish_name;
|
||||
const nameB = lang === "en" ? b.name : b.spanish_name;
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map(pt => (
|
||||
<option key={pt.id} value={pt.id}>
|
||||
{lang === "en" ? pt.name : pt.spanish_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Categoría" : "Category"}</label>
|
||||
<select name="new_category" value={form.new_category} onChange={handleChange} required>
|
||||
<option value="">{lang === "es" ? "Selecciona categoría" : "Select category"}</option>
|
||||
{categories
|
||||
.sort((a, b) => {
|
||||
const nameA = lang === "en" ? a.name : a.spanish_name;
|
||||
const nameB = lang === "en" ? b.name : b.spanish_name;
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{lang === "en" ? cat.name : cat.spanish_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Proveedor" : "Supplier"}</label>
|
||||
<select name="new_rfc_suppliers" value={form.new_rfc_suppliers} onChange={handleChange} required>
|
||||
<option value="">{lang === "es" ? "Selecciona proveedor" : "Select supplier"}</option>
|
||||
{suppliers.map(sup => (
|
||||
<option key={sup.rfc_suppliers} value={sup.rfc_suppliers}>{sup.name_suppliers}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Unidad" : "Unit"}</label>
|
||||
<select name="unit" value={form.unit} onChange={handleChange} required>
|
||||
<option value="">{lang === "es" ? "Selecciona unidad" : "Select unit"}</option>
|
||||
{units
|
||||
.sort((a, b) => {
|
||||
const nameA = lang === "en" ? a.name : a.spanish_name;
|
||||
const nameB = lang === "en" ? b.name : b.spanish_name;
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map(unit => (
|
||||
<option key={unit.id} value={unit.id}>
|
||||
{lang === "en" ? unit.name : unit.spanish_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{getUnitType() === 'package' && (
|
||||
<>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Cantidad de paquetes" : "Package Quantity"}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={packageQuantity}
|
||||
onChange={(e) => setPackageQuantity(e.target.value)}
|
||||
required
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Piezas por paquete" : "Pieces per Package"}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={piecesPerPackage}
|
||||
onChange={(e) => setPiecesPerPackage(e.target.value)}
|
||||
required
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{getUnitType() === 'pair' && (
|
||||
<div>
|
||||
<label>{lang === "es" ? "Número de pares" : "Number of Pairs"}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={numberOfPairs}
|
||||
onChange={(e) => setNumberOfPairs(e.target.value)}
|
||||
required
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getUnitType() === 'dozen' && (
|
||||
<div>
|
||||
<label>{lang === "es" ? "Número de docenas" : "Number of Dozens"}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={numberOfDozens}
|
||||
onChange={(e) => setNumberOfDozens(e.target.value)}
|
||||
required
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label>Stock</label>
|
||||
<input name="new_stock" value={form.new_stock} onChange={handleChange} type="number" required />
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Precio" : "Price"}</label>
|
||||
<input name="newprice_product" value={form.newprice_product} onChange={handleChange} type="number" step="0.01" required />
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Impuesto" : "Tax"}</label>
|
||||
<select name="new_tax" value={form.new_tax} onChange={handleChange} required>
|
||||
<option value="">{lang === "es" ? "Selecciona impuesto" : "Select tax"}</option>
|
||||
<option value="1">16%</option>
|
||||
<option value="2">8%</option>
|
||||
<option value="3">0%</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Moneda" : "Currency"}</label>
|
||||
<select name="currency" value={form.currency} onChange={handleChange} required>
|
||||
<option value="">{lang === "es" ? "Selecciona moneda" : "Select currency"}</option>
|
||||
{currencies.map(cur => (
|
||||
<option key={cur.id} value={cur.id}>{cur.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>{lang === "es" ? "Imagen del producto" : "Product Image"}</label>
|
||||
<input type="file" accept="image/*" onChange={handleImage} />
|
||||
</div>
|
||||
<div className="save-button-wrapper">
|
||||
<button type="submit" className="save-button" disabled={loading}>{loading ? (lang === "es" ? 'Guardando...' : 'Saving...') : (lang === "es" ? 'Guardar producto' : 'Save Product')}</button>
|
||||
</div>
|
||||
{success && <span style={{ color: 'green' }}>{lang === "es" ? '¡Producto guardado!' : 'Product saved!'}</span>}
|
||||
{error && <span style={{ color: 'red' }}>{error}</span>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
42
frontend/Frontend-Hotel/src/pages/Inventory/Outcomes.css
Normal file
42
frontend/Frontend-Hotel/src/pages/Inventory/Outcomes.css
Normal file
@@ -0,0 +1,42 @@
|
||||
.outcomes-page .filters-section {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.outcomes-page .filters-section .filter-search {
|
||||
flex: 1;
|
||||
max-width: 280px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.outcomes-page .filters-section .filter-select {
|
||||
flex: 0 0 auto;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.outcomes-page .filters-section {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.outcomes-page .filters-section .filter-search {
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.outcomes-page .filters-section .filter-select {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
314
frontend/Frontend-Hotel/src/pages/Inventory/Outcomes.jsx
Normal file
314
frontend/Frontend-Hotel/src/pages/Inventory/Outcomes.jsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import '../../components/Filters/Filters.css';
|
||||
import './Outcomes.css';
|
||||
import { langContext } from '../../context/LenguageContext';
|
||||
import ConfirmationOutcome from '../../components/Modals/ConfirmationOutcome';
|
||||
import axios from 'axios';
|
||||
|
||||
const IMAGE_BASE_URL = import.meta.env.VITE_API_BASE_URL + '/products';
|
||||
|
||||
|
||||
export default function Outcomes() {
|
||||
const { lang } = useContext(langContext);
|
||||
const [products, setProducts] = useState([]);
|
||||
const [filteredProducts, setFilteredProducts] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [nameFilter, setNameFilter] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [stockFilter, setStockFilter] = useState('');
|
||||
const [formHousekepeer, setFormHousekepeer] = useState(null)
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [selectedProdName, setSelectedProdName] = useState(null);
|
||||
const [selectedProductStock, setSelectedProductStock] = useState(null);
|
||||
|
||||
const handleOpenModal = (id, product, productStock) => {
|
||||
setSelectedId(id);
|
||||
setSelectedProdName(product);
|
||||
setSelectedProductStock(productStock);
|
||||
//setSelectedStatus(statusType);
|
||||
//setSelectedStatusId(statusId);
|
||||
setModalOpen(true);
|
||||
};
|
||||
const handleConfirm = (PCO, UCO, HCO) => {
|
||||
if (!PCO || !UCO || !HCO) return;
|
||||
|
||||
const currentdate = new Date();
|
||||
axios.post(`${import.meta.env.VITE_API_BASE_URL}/products/newconsumptionstock`, {
|
||||
"product_id": PCO,
|
||||
"quantity_consumption": UCO,
|
||||
"date_consumption": currentdate.getFullYear() + "-" + (currentdate.getMonth() + 1) + "-" + currentdate.getDate(),
|
||||
"rfc_emp": HCO
|
||||
})
|
||||
.then((res) => {
|
||||
console.log(res || 'Outcome realizado correctamente');
|
||||
// Cerrar el modal y limpiar selección
|
||||
setModalOpen(false);
|
||||
setSelectedId(null);
|
||||
setSelectedProdName(null);
|
||||
setSelectedProductStock(null);
|
||||
async function loadProducts() {
|
||||
try {
|
||||
const response = await fetch(import.meta.env.VITE_API_BASE_URL + '/products');
|
||||
const data = await response.json();
|
||||
const productsList = Array.isArray(data.data) ? data.data : [];
|
||||
|
||||
const productsWithDetails = await Promise.all(
|
||||
productsList.map(async (prod) => {
|
||||
try {
|
||||
const detailResponse = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/products/product/${prod.id_product}`,
|
||||
{ method: "PUT" }
|
||||
);
|
||||
const detailData = await detailResponse.json();
|
||||
if (detailData.data && detailData.data.length > 0) {
|
||||
const detail = detailData.data[0];
|
||||
return {
|
||||
...prod,
|
||||
category: detail.id_category_pro || null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prod,
|
||||
category: null,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Error fetching product ${prod.id_product}:`, err);
|
||||
return {
|
||||
...prod,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setProducts(productsWithDetails);
|
||||
setFilteredProducts(productsWithDetails);
|
||||
} catch (err) {
|
||||
console.error('Error fetching products', err);
|
||||
setProducts([]);
|
||||
setFilteredProducts([]);
|
||||
}
|
||||
}
|
||||
loadProducts();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Error al actualizar el outcome con el producto ${selectedId}:`, err);
|
||||
alert('❌ Error al actualizar el outcome.');
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function loadProducts() {
|
||||
try {
|
||||
const response = await fetch(import.meta.env.VITE_API_BASE_URL + '/products');
|
||||
const data = await response.json();
|
||||
const productsList = Array.isArray(data.data) ? data.data : [];
|
||||
|
||||
const productsWithDetails = await Promise.all(
|
||||
productsList.map(async (prod) => {
|
||||
try {
|
||||
const detailResponse = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/products/product/${prod.id_product}`,
|
||||
{ method: "PUT" }
|
||||
);
|
||||
const detailData = await detailResponse.json();
|
||||
if (detailData.data && detailData.data.length > 0) {
|
||||
const detail = detailData.data[0];
|
||||
return {
|
||||
...prod,
|
||||
category: detail.id_category_pro || null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prod,
|
||||
category: null,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Error fetching product ${prod.id_product}:`, err);
|
||||
return {
|
||||
...prod,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setProducts(productsWithDetails);
|
||||
setFilteredProducts(productsWithDetails);
|
||||
} catch (err) {
|
||||
console.error('Error fetching products', err);
|
||||
setProducts([]);
|
||||
setFilteredProducts([]);
|
||||
}
|
||||
}
|
||||
async function fetchSelectData() {
|
||||
try {
|
||||
const res = await fetch(import.meta.env.VITE_API_BASE_URL + '/products/gethousekeeper', {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (!res.ok) throw new Error(`Error fetching info: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setFormHousekepeer(data.houseKeeper || []);
|
||||
} catch (err) {
|
||||
console.error('Error cargando metadata (getinfo):', err);
|
||||
}
|
||||
}
|
||||
fetchSelectData();
|
||||
loadProducts();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadFilterOptions() {
|
||||
try {
|
||||
const categoriesRes = await fetch(
|
||||
import.meta.env.VITE_API_BASE_URL + "/products/productcategory"
|
||||
);
|
||||
|
||||
const categoriesData = await categoriesRes.json();
|
||||
|
||||
if (categoriesData.data) {
|
||||
setCategories(
|
||||
categoriesData.data
|
||||
.filter((d) => d.id_prod_category)
|
||||
.map((d) => ({
|
||||
id: d.id_prod_category,
|
||||
name: d.name_prod_category,
|
||||
spanish_name: d.spanish_name || d.name_prod_category,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error loading filter options", err);
|
||||
}
|
||||
}
|
||||
loadFilterOptions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let filtered = [...products];
|
||||
|
||||
if (nameFilter) {
|
||||
filtered = filtered.filter(prod =>
|
||||
prod.name_product?.toLowerCase().includes(nameFilter.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (categoryFilter) {
|
||||
filtered = filtered.filter(
|
||||
(prod) => prod.category && prod.category.toString() === categoryFilter
|
||||
);
|
||||
}
|
||||
|
||||
if (stockFilter) {
|
||||
switch (stockFilter) {
|
||||
case 'out':
|
||||
filtered = filtered.filter(prod => (parseInt(prod.units) || 0) === 0);
|
||||
break;
|
||||
case 'low':
|
||||
filtered = filtered.filter(prod => {
|
||||
const units = parseInt(prod.units) || 0;
|
||||
return units > 0 && units < 10;
|
||||
});
|
||||
break;
|
||||
case 'in':
|
||||
filtered = filtered.filter(prod => (parseInt(prod.units) || 0) >= 10);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setFilteredProducts(filtered);
|
||||
}, [nameFilter, categoryFilter, stockFilter, products]);
|
||||
|
||||
return (
|
||||
<div className="outcomes-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">{lang === "es" ? "Productos" : "Products"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="filters-section">
|
||||
<input
|
||||
type="text"
|
||||
className="filter-search"
|
||||
placeholder={lang === 'es' ? 'Search by name...' : 'Search by name...'}
|
||||
value={nameFilter}
|
||||
onChange={(e) => setNameFilter(e.target.value)}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">
|
||||
{lang === "es" ? "Todas las categorías" : "All Categories"}
|
||||
</option>
|
||||
{categories
|
||||
.sort((a, b) => {
|
||||
const nameA = lang === "en" ? a.name : a.spanish_name;
|
||||
const nameB = lang === "en" ? b.name : b.spanish_name;
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map((cat, index) => (
|
||||
<option key={index} value={cat.id}>
|
||||
{lang === "en" ? cat.name : cat.spanish_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={stockFilter}
|
||||
onChange={(e) => setStockFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">{lang === "es" ? "Todo el stock" : "All Stock"}</option>
|
||||
<option value="out">{lang === "es" ? "Sin stock" : "Out of Stock"}</option>
|
||||
<option value="low">{lang === "es" ? "Stock bajo (< 10)" : "Low Stock (< 10)"}</option>
|
||||
<option value="in">{lang === "es" ? "En stock (≥ 10)" : "In Stock (≥ 10)"}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="products-grid">
|
||||
{Array.isArray(filteredProducts) &&
|
||||
filteredProducts.map((prod, index) => (
|
||||
<div key={prod.id_product || index} className="product-card" onClick={() => handleOpenModal(prod.id_product, prod.name_product, prod.units)}>
|
||||
{prod.image_product && (
|
||||
<img
|
||||
src={
|
||||
prod.image_product.startsWith('http')
|
||||
? prod.image_product
|
||||
: prod.image_product.length > 200
|
||||
? `data:image/jpeg;base64,${prod.image_product}`
|
||||
: `${IMAGE_BASE_URL}/${prod.image_product}`
|
||||
}
|
||||
alt={prod.name_product}
|
||||
className="product-image"
|
||||
/>
|
||||
)}
|
||||
<div className="product-info">
|
||||
<div className="product-name">{prod.name_product}</div>
|
||||
<div className="product-price">{lang === "es" ? "Precio: $" : "Price: $"}{prod.price_product}</div>
|
||||
<div className="product-units">{lang === "es" ? "Unidades: " : "Units: "}{prod.units}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ConfirmationOutcome
|
||||
isOpen={modalOpen}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
formHousekepeer={formHousekepeer}
|
||||
idproduct={selectedId}
|
||||
nameProduct={selectedProdName}
|
||||
productStock={selectedProductStock}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
frontend/Frontend-Hotel/src/pages/Inventory/Products.css
Normal file
155
frontend/Frontend-Hotel/src/pages/Inventory/Products.css
Normal file
@@ -0,0 +1,155 @@
|
||||
/* src/pages/Inventory/Products.css */
|
||||
|
||||
.products-page-container {
|
||||
padding: 20px;
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.products-page-container h2 {
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.products-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
background-color: white;
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: transform 0.2s;
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.add-new-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
font-size: 3em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
max-width: 100%;
|
||||
height: 120px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.product-price,
|
||||
.product-units {
|
||||
font-size: 0.9em;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.page-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-filters select {
|
||||
color: #213547;
|
||||
background-color: white;
|
||||
max-width: 250px;
|
||||
min-width: 150px;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.page-filters input[type="text"],
|
||||
.page-filters .filter-search-input {
|
||||
color: #213547;
|
||||
background-color: white;
|
||||
max-width: 300px;
|
||||
min-width: 200px;
|
||||
flex: 0 1 auto;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
box-shadow: 0 0 0 2px #f4f4f4;
|
||||
font-size: 14px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: bold;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-filters p {
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.products-page-container p {
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.products-filters {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.products-filters .filter-search {
|
||||
flex: 1;
|
||||
max-width: 280px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.products-filters {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.products-filters .filter-search {
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.products-filters .filter-select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.products-filters .clear-filters-btn {
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user