- 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)
439 lines
14 KiB
JavaScript
439 lines
14 KiB
JavaScript
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>
|
|
);
|
|
}
|