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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user