feat: add housekeeping, room service, and events modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 01:48:23 +00:00
parent 785b1f0925
commit b0887286c1
17 changed files with 2437 additions and 0 deletions

View File

@@ -38,6 +38,9 @@ const roomsRoutes = require('./routes/rooms.routes');
const dashboardRoutes = require('./routes/dashboard.routes');
const reservationsRoutes = require('./routes/reservations.routes');
const guestsRoutes = require('./routes/guests.routes');
const housekeepingRoutes = require('./routes/housekeeping.routes');
const roomserviceRoutes = require('./routes/roomservice.routes');
const eventsRoutes = require('./routes/events.routes');
//Prefijo - Auth routes are public (no middleware)
app.use('/api/auth', authRoutes);
@@ -62,5 +65,8 @@ app.use('/api/rooms', authMiddleware, roomsRoutes);
app.use('/api/dashboard', authMiddleware, dashboardRoutes);
app.use('/api/reservations', authMiddleware, reservationsRoutes);
app.use('/api/guests', authMiddleware, guestsRoutes);
app.use('/api/housekeeping', authMiddleware, housekeepingRoutes);
app.use('/api/room-service', authMiddleware, roomserviceRoutes);
app.use('/api/events', authMiddleware, eventsRoutes);
module.exports = app;

View File

@@ -0,0 +1,111 @@
const pool = require('../db/connection');
const getVenues = async (req, res) => {
try {
const result = await pool.query('SELECT * FROM venues ORDER BY name');
res.json({ venues: result.rows });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al obtener salones' });
}
};
const createVenue = async (req, res) => {
try {
const { name, capacity, area_sqm, price_per_hour, amenities, description } = req.body;
const result = await pool.query(
'INSERT INTO venues (name, capacity, area_sqm, price_per_hour, amenities, description) VALUES ($1, $2, $3, $4, $5::jsonb, $6) RETURNING *',
[name, capacity, area_sqm, price_per_hour, JSON.stringify(amenities || []), description]
);
res.status(201).json({ venue: result.rows[0] });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al crear salon' });
}
};
const updateVenue = async (req, res) => {
try {
const { id } = req.params;
const { name, capacity, area_sqm, price_per_hour, amenities, description, status } = req.body;
const result = await pool.query(
`UPDATE venues SET name = COALESCE($1, name), capacity = COALESCE($2, capacity),
area_sqm = COALESCE($3, area_sqm), price_per_hour = COALESCE($4, price_per_hour),
amenities = COALESCE($5::jsonb, amenities), description = COALESCE($6, description),
status = COALESCE($7, status)
WHERE id = $8 RETURNING *`,
[name, capacity, area_sqm, price_per_hour, amenities ? JSON.stringify(amenities) : null, description, status, id]
);
if (result.rows.length === 0) return res.status(404).json({ message: 'Salon no encontrado' });
res.json({ venue: result.rows[0] });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al actualizar salon' });
}
};
const getEvents = async (req, res) => {
try {
const { from_date, to_date } = req.query;
let query = 'SELECT e.*, v.name as venue_name, v.capacity FROM events e JOIN venues v ON v.id = e.venue_id WHERE 1=1';
const params = [];
let idx = 1;
if (from_date) { query += ` AND e.event_date >= $${idx++}`; params.push(from_date); }
if (to_date) { query += ` AND e.event_date <= $${idx++}`; params.push(to_date); }
query += ' ORDER BY e.event_date, e.start_time';
const result = await pool.query(query, params);
res.json({ events: result.rows });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al obtener eventos' });
}
};
const createEvent = async (req, res) => {
try {
const { venue_id, name, organizer, event_date, start_time, end_time, guest_count, notes, total_amount } = req.body;
// Check venue availability
const overlap = await pool.query(
`SELECT id FROM events WHERE venue_id = $1 AND event_date = $2
AND ((start_time < $4 AND end_time > $3)) AND status != 'cancelled'`,
[venue_id, event_date, start_time, end_time]
);
if (overlap.rows.length > 0) {
return res.status(409).json({ message: 'El salon no esta disponible en ese horario' });
}
const result = await pool.query(
`INSERT INTO events (venue_id, name, organizer, event_date, start_time, end_time, guest_count, notes, total_amount)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[venue_id, name, organizer, event_date, start_time, end_time, guest_count, notes, total_amount]
);
res.status(201).json({ event: result.rows[0] });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al crear evento' });
}
};
const updateEvent = async (req, res) => {
try {
const { id } = req.params;
const { venue_id, name, organizer, event_date, start_time, end_time, guest_count, notes, total_amount, status } = req.body;
const result = await pool.query(
`UPDATE events SET venue_id = COALESCE($1, venue_id), name = COALESCE($2, name),
organizer = COALESCE($3, organizer), event_date = COALESCE($4, event_date),
start_time = COALESCE($5, start_time), end_time = COALESCE($6, end_time),
guest_count = COALESCE($7, guest_count), notes = COALESCE($8, notes),
total_amount = COALESCE($9, total_amount), status = COALESCE($10, status)
WHERE id = $11 RETURNING *`,
[venue_id, name, organizer, event_date, start_time, end_time, guest_count, notes, total_amount, status, id]
);
if (result.rows.length === 0) return res.status(404).json({ message: 'Evento no encontrado' });
res.json({ event: result.rows[0] });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al actualizar evento' });
}
};
module.exports = { getVenues, createVenue, updateVenue, getEvents, createEvent, updateEvent };

View File

@@ -0,0 +1,109 @@
const pool = require('../db/connection');
const getTasks = async (req, res) => {
try {
const { status, priority, assigned_to } = req.query;
let query = `
SELECT ht.*, rm.name_room, rm.floor,
CASE WHEN ht.assigned_to IS NOT NULL THEN
(SELECT e.first_name || ' ' || e.last_name FROM employees e WHERE e.id_employee = ht.assigned_to)
END as assigned_name
FROM housekeeping_tasks ht
LEFT JOIN rooms rm ON rm.id_room = ht.room_id
WHERE 1=1
`;
const params = [];
let idx = 1;
if (status) { query += ` AND ht.status = $${idx++}`; params.push(status); }
if (priority) { query += ` AND ht.priority = $${idx++}`; params.push(priority); }
if (assigned_to) { query += ` AND ht.assigned_to = $${idx++}`; params.push(assigned_to); }
query += ' ORDER BY CASE ht.priority WHEN \'high\' THEN 1 WHEN \'normal\' THEN 2 WHEN \'low\' THEN 3 END, ht.created_at DESC';
const result = await pool.query(query, params);
res.json({ tasks: result.rows });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al obtener tareas' });
}
};
const createTask = async (req, res) => {
try {
const { room_id, priority, type, notes } = req.body;
const result = await pool.query(
'INSERT INTO housekeeping_tasks (room_id, priority, type, notes) VALUES ($1, $2, $3, $4) RETURNING *',
[room_id, priority || 'normal', type, notes]
);
res.status(201).json({ task: result.rows[0] });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al crear tarea' });
}
};
const updateTask = async (req, res) => {
try {
const { id } = req.params;
const { assigned_to, status, notes } = req.body;
const current = await pool.query('SELECT * FROM housekeeping_tasks WHERE id = $1', [id]);
if (current.rows.length === 0) return res.status(404).json({ message: 'Tarea no encontrada' });
const task = current.rows[0];
const updates = {};
if (assigned_to !== undefined) updates.assigned_to = assigned_to;
if (notes !== undefined) updates.notes = notes;
if (status === 'in_progress' && task.status === 'pending') {
updates.status = 'in_progress';
updates.started_at = new Date();
} else if (status === 'completed' && task.status === 'in_progress') {
updates.status = 'completed';
updates.completed_at = new Date();
// Set room to available
await pool.query("UPDATE rooms SET status = 'available' WHERE id_room = $1", [task.room_id]);
await pool.query(
'INSERT INTO room_status_log (room_id, previous_status, new_status, changed_by) VALUES ($1, $2, $3, $4)',
[task.room_id, 'cleaning', 'available', req.user?.user_id]
);
} else if (status) {
updates.status = status;
}
const setClauses = Object.keys(updates).map((key, i) => `${key} = $${i + 1}`);
const values = Object.values(updates);
if (setClauses.length === 0) return res.json({ task: task });
const result = await pool.query(
`UPDATE housekeeping_tasks SET ${setClauses.join(', ')} WHERE id = $${values.length + 1} RETURNING *`,
[...values, id]
);
res.json({ task: result.rows[0] });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al actualizar tarea' });
}
};
const getStaff = async (req, res) => {
try {
// Get employees from housekeeping-related areas
// Note: the employees table structure uses stored functions, so we query directly
const result = await pool.query(`
SELECT e.id_employee, e.first_name, e.last_name,
(SELECT COUNT(*) FROM housekeeping_tasks ht WHERE ht.assigned_to = e.id_employee AND ht.status = 'in_progress') as active_tasks
FROM employees e
WHERE e.status_employee = true
ORDER BY e.first_name
LIMIT 50
`);
res.json({ staff: result.rows });
} catch (error) {
console.error(error);
// If employee table structure is different, return empty
res.json({ staff: [] });
}
};
module.exports = { getTasks, createTask, updateTask, getStaff };

View File

@@ -0,0 +1,130 @@
const pool = require('../db/connection');
const getOrders = async (req, res) => {
try {
const { status } = req.query;
let query = `
SELECT rso.*, rm.name_room, g.first_name || ' ' || g.last_name as guest_name,
json_agg(json_build_object('id', oi.id, 'name', mi.name, 'quantity', oi.quantity, 'price', oi.price, 'notes', oi.notes)) as items
FROM room_service_orders rso
LEFT JOIN rooms rm ON rm.id_room = rso.room_id
LEFT JOIN guests g ON g.id = rso.guest_id
LEFT JOIN order_items oi ON oi.order_id = rso.id
LEFT JOIN menu_items mi ON mi.id = oi.menu_item_id
`;
const params = [];
if (status) {
query += ' WHERE rso.status = $1';
params.push(status);
} else {
query += " WHERE rso.status NOT IN ('delivered', 'cancelled')";
}
query += ' GROUP BY rso.id, rm.name_room, g.first_name, g.last_name ORDER BY rso.created_at DESC';
const result = await pool.query(query, params);
res.json({ orders: result.rows });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al obtener ordenes' });
}
};
const createOrder = async (req, res) => {
try {
const { room_id, guest_id, items, notes } = req.body;
// Calculate total
let total = 0;
for (const item of items) {
const menuItem = await pool.query('SELECT price FROM menu_items WHERE id = $1', [item.menu_item_id]);
if (menuItem.rows.length > 0) {
total += parseFloat(menuItem.rows[0].price) * item.quantity;
}
}
const orderResult = await pool.query(
'INSERT INTO room_service_orders (room_id, guest_id, total, notes) VALUES ($1, $2, $3, $4) RETURNING *',
[room_id, guest_id || null, total, notes]
);
const orderId = orderResult.rows[0].id;
// Insert order items
for (const item of items) {
const menuItem = await pool.query('SELECT price FROM menu_items WHERE id = $1', [item.menu_item_id]);
const price = menuItem.rows.length > 0 ? menuItem.rows[0].price : 0;
await pool.query(
'INSERT INTO order_items (order_id, menu_item_id, quantity, price, notes) VALUES ($1, $2, $3, $4, $5)',
[orderId, item.menu_item_id, item.quantity, price, item.notes || null]
);
}
res.status(201).json({ order: orderResult.rows[0], message: 'Orden creada correctamente' });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al crear orden' });
}
};
const updateOrderStatus = async (req, res) => {
try {
const { id } = req.params;
const { status } = req.body;
const validStatuses = ['pending', 'preparing', 'delivering', 'delivered', 'cancelled'];
if (!validStatuses.includes(status)) return res.status(400).json({ message: 'Estado invalido' });
const result = await pool.query(
'UPDATE room_service_orders SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
[status, id]
);
if (result.rows.length === 0) return res.status(404).json({ message: 'Orden no encontrada' });
res.json({ order: result.rows[0] });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al actualizar orden' });
}
};
const getMenu = async (req, res) => {
try {
const result = await pool.query('SELECT * FROM menu_items ORDER BY category, name');
res.json({ menu: result.rows });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al obtener menu' });
}
};
const createMenuItem = async (req, res) => {
try {
const { name, name_es, description, description_es, price, category } = req.body;
const result = await pool.query(
'INSERT INTO menu_items (name, name_es, description, description_es, price, category) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *',
[name, name_es, description, description_es, price, category]
);
res.status(201).json({ item: result.rows[0] });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al crear platillo' });
}
};
const updateMenuItem = async (req, res) => {
try {
const { id } = req.params;
const { name, name_es, description, description_es, price, category, available } = req.body;
const result = await pool.query(
`UPDATE menu_items SET name = COALESCE($1, name), name_es = COALESCE($2, name_es),
description = COALESCE($3, description), description_es = COALESCE($4, description_es),
price = COALESCE($5, price), category = COALESCE($6, category), available = COALESCE($7, available)
WHERE id = $8 RETURNING *`,
[name, name_es, description, description_es, price, category, available, id]
);
if (result.rows.length === 0) return res.status(404).json({ message: 'Platillo no encontrado' });
res.json({ item: result.rows[0] });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al actualizar platillo' });
}
};
module.exports = { getOrders, createOrder, updateOrderStatus, getMenu, createMenuItem, updateMenuItem };

View File

@@ -0,0 +1,12 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/events.controller');
router.get('/venues', ctrl.getVenues);
router.post('/venues', ctrl.createVenue);
router.put('/venues/:id', ctrl.updateVenue);
router.get('/', ctrl.getEvents);
router.post('/', ctrl.createEvent);
router.put('/:id', ctrl.updateEvent);
module.exports = router;

View File

@@ -0,0 +1,10 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/housekeeping.controller');
router.get('/tasks', ctrl.getTasks);
router.get('/staff', ctrl.getStaff);
router.post('/tasks', ctrl.createTask);
router.put('/tasks/:id', ctrl.updateTask);
module.exports = router;

View File

@@ -0,0 +1,12 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/roomservice.controller');
router.get('/orders', ctrl.getOrders);
router.post('/orders', ctrl.createOrder);
router.put('/orders/:id/status', ctrl.updateOrderStatus);
router.get('/menu', ctrl.getMenu);
router.post('/menu', ctrl.createMenuItem);
router.put('/menu/:id', ctrl.updateMenuItem);
module.exports = router;

View File

@@ -44,6 +44,11 @@ import Reservations from "./pages/Reservations/Reservations.jsx";
import NewReservation from "./pages/Reservations/NewReservation.jsx";
import Guests from "./pages/Guests/Guests.jsx";
import GuestDetail from "./pages/Guests/GuestDetail.jsx";
import Housekeeping from "./pages/Housekeeping/Housekeeping.jsx";
import RoomServiceOrders from "./pages/RoomService/RoomServiceOrders.jsx";
import MenuManager from "./pages/RoomService/MenuManager.jsx";
import NewOrder from "./pages/RoomService/NewOrder.jsx";
import EventsVenues from "./pages/Events/EventsVenues.jsx";
import "./styles/global.css";
//Submenú de Hotel
@@ -141,6 +146,11 @@ export default function App() {
<Route path="reservations/new" element={<NewReservation />} />
<Route path="guests" element={<Guests />} />
<Route path="guests/:id" element={<GuestDetail />} />
<Route path="housekeeping" element={<Housekeeping />} />
<Route path="room-service" element={<RoomServiceOrders />} />
<Route path="room-service/menu" element={<MenuManager />} />
<Route path="room-service/new-order" element={<NewOrder />} />
<Route path="events" element={<EventsVenues />} />
</Route>
</Routes>
</>

View File

@@ -91,6 +91,16 @@ export const menuConfig = {
{ label: "Contracts", spanish_label: "Contratos", route: "/app/payroll/contract" }
],
},
services: {
label: "Services",
spanish_label: "Servicios",
basePath: "/app/housekeeping",
submenu: [
{ label: "Housekeeping", spanish_label: "Limpieza", route: "/app/housekeeping" },
{ label: "Room Service", spanish_label: "Servicio a Habitacion", route: "/app/room-service" },
{ label: "Events & Venues", spanish_label: "Eventos y Salones", route: "/app/events" },
],
},
// hotel: {
// label: "Hotel",
// spanish_label: "Hotel",

View File

@@ -0,0 +1,761 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
getVenues,
createVenue,
getEvents,
createEvent,
} from "../../services/eventService";
export default function EventsVenues() {
const { t } = useTranslation();
const [venues, setVenues] = useState([]);
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [showVenueModal, setShowVenueModal] = useState(false);
const [showEventModal, setShowEventModal] = useState(false);
const [venueForm, setVenueForm] = useState({
name: "",
capacity: "",
area_sqm: "",
price_per_hour: "",
amenities: "",
description: "",
});
const [eventForm, setEventForm] = useState({
venue_id: "",
name: "",
organizer: "",
event_date: "",
start_time: "",
end_time: "",
guest_count: "",
notes: "",
total_amount: "",
});
const fetchData = async () => {
try {
const [venuesRes, eventsRes] = await Promise.all([
getVenues(),
getEvents(),
]);
setVenues(venuesRes.data.venues || []);
setEvents(eventsRes.data.events || []);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleCreateVenue = async (e) => {
e.preventDefault();
try {
const amenitiesList = venueForm.amenities
? venueForm.amenities.split(",").map((a) => a.trim()).filter(Boolean)
: [];
await createVenue({
...venueForm,
capacity: parseInt(venueForm.capacity),
area_sqm: parseFloat(venueForm.area_sqm) || null,
price_per_hour: parseFloat(venueForm.price_per_hour),
amenities: amenitiesList,
});
setShowVenueModal(false);
setVenueForm({
name: "",
capacity: "",
area_sqm: "",
price_per_hour: "",
amenities: "",
description: "",
});
fetchData();
} catch (error) {
console.error(error);
}
};
const handleCreateEvent = async (e) => {
e.preventDefault();
try {
await createEvent({
...eventForm,
venue_id: parseInt(eventForm.venue_id),
guest_count: parseInt(eventForm.guest_count) || null,
total_amount: parseFloat(eventForm.total_amount) || null,
});
setShowEventModal(false);
setEventForm({
venue_id: "",
name: "",
organizer: "",
event_date: "",
start_time: "",
end_time: "",
guest_count: "",
notes: "",
total_amount: "",
});
fetchData();
} catch (error) {
console.error(error);
}
};
const formatDate = (dateStr) => {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString();
};
const formatTime = (timeStr) => {
if (!timeStr) return "";
return timeStr.substring(0, 5);
};
const getStatusBadge = (status) => {
switch (status) {
case "available":
return "badge badge-success";
case "reserved":
return "badge badge-warning";
default:
return "badge";
}
};
const getEventStatusBadge = (status) => {
switch (status) {
case "confirmed":
return "badge badge-success";
case "pending":
return "badge badge-warning";
case "cancelled":
return "badge badge-error";
case "completed":
return "badge badge-info";
default:
return "badge";
}
};
if (loading) {
return (
<div className="empty-state">
<p>{t("common.loading")}</p>
</div>
);
}
return (
<div>
{/* Venues Section */}
<div className="page-header">
<h2>{t("events.title")}</h2>
<div style={{ display: "flex", gap: "var(--space-sm)" }}>
<button className="btn-outline" onClick={() => setShowVenueModal(true)}>
{t("events.newVenue")}
</button>
<button className="btn-gold" onClick={() => setShowEventModal(true)}>
{t("events.newEvent")}
</button>
</div>
</div>
{/* Venues */}
<h3
style={{
color: "var(--text-primary)",
marginBottom: "var(--space-md)",
fontSize: "1.1rem",
}}
>
{t("events.venues")}
</h3>
{venues.length === 0 ? (
<div className="empty-state" style={{ marginBottom: "var(--space-xl)" }}>
<p>{t("common.noResults")}</p>
</div>
) : (
<div className="grid-3" style={{ marginBottom: "var(--space-xl)" }}>
{venues.map((venue) => (
<div key={venue.id} className="card">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
<h3
style={{
margin: 0,
fontSize: "1rem",
color: "var(--text-primary)",
}}
>
{venue.name}
</h3>
<span className={getStatusBadge(venue.status || "available")}>
{t(`events.venueStatus.${venue.status || "available"}`)}
</span>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "var(--space-sm)",
marginTop: "var(--space-md)",
}}
>
<div>
<p
style={{
margin: 0,
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
>
{t("events.capacity")}
</p>
<p
style={{
margin: 0,
fontSize: "0.95rem",
color: "var(--text-primary)",
fontWeight: 500,
}}
>
{venue.capacity}
</p>
</div>
<div>
<p
style={{
margin: 0,
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
>
{t("events.area")}
</p>
<p
style={{
margin: 0,
fontSize: "0.95rem",
color: "var(--text-primary)",
fontWeight: 500,
}}
>
{venue.area_sqm || "-"}
</p>
</div>
</div>
<div style={{ marginTop: "var(--space-sm)" }}>
<p
style={{
margin: 0,
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
>
{t("events.pricePerHour")}
</p>
<p
style={{
margin: 0,
fontSize: "1rem",
color: "var(--accent-gold, #d4a853)",
fontWeight: 600,
}}
>
${parseFloat(venue.price_per_hour || 0).toFixed(2)}
</p>
</div>
{venue.amenities && Array.isArray(venue.amenities) && venue.amenities.length > 0 && (
<div
style={{
marginTop: "var(--space-sm)",
display: "flex",
gap: "var(--space-xs)",
flexWrap: "wrap",
}}
>
{venue.amenities.map((amenity, idx) => (
<span key={idx} className="badge" style={{ fontSize: "0.7rem" }}>
{amenity}
</span>
))}
</div>
)}
</div>
))}
</div>
)}
{/* Upcoming Events */}
<h3
style={{
color: "var(--text-primary)",
marginBottom: "var(--space-md)",
fontSize: "1.1rem",
}}
>
{t("events.upcomingEvents")}
</h3>
{events.length === 0 ? (
<div className="empty-state">
<p>{t("common.noResults")}</p>
</div>
) : (
<div className="grid-3">
{events.map((event) => (
<div key={event.id} className="card">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
<h3
style={{
margin: 0,
fontSize: "1rem",
color: "var(--text-primary)",
}}
>
{event.name}
</h3>
<span className={getEventStatusBadge(event.status)}>
{event.status}
</span>
</div>
<p
style={{
margin: "var(--space-xs) 0 0",
fontSize: "0.85rem",
color: "var(--text-secondary)",
}}
>
{event.venue_name}
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "var(--space-sm)",
marginTop: "var(--space-md)",
}}
>
<div>
<p
style={{
margin: 0,
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
>
{t("events.eventDate")}
</p>
<p
style={{
margin: 0,
fontSize: "0.9rem",
color: "var(--text-primary)",
}}
>
{formatDate(event.event_date)}
</p>
</div>
<div>
<p
style={{
margin: 0,
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
>
{t("events.startTime")} - {t("events.endTime")}
</p>
<p
style={{
margin: 0,
fontSize: "0.9rem",
color: "var(--text-primary)",
}}
>
{formatTime(event.start_time)} - {formatTime(event.end_time)}
</p>
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "var(--space-sm)",
marginTop: "var(--space-sm)",
}}
>
<div>
<p
style={{
margin: 0,
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
>
{t("events.organizer")}
</p>
<p
style={{
margin: 0,
fontSize: "0.9rem",
color: "var(--text-primary)",
}}
>
{event.organizer}
</p>
</div>
<div>
<p
style={{
margin: 0,
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
>
{t("events.guestCount")}
</p>
<p
style={{
margin: 0,
fontSize: "0.9rem",
color: "var(--text-primary)",
}}
>
{event.guest_count || "-"}
</p>
</div>
</div>
{event.total_amount && (
<div style={{ marginTop: "var(--space-sm)" }}>
<p
style={{
margin: 0,
fontSize: "0.9rem",
color: "var(--accent-gold, #d4a853)",
fontWeight: 600,
}}
>
${parseFloat(event.total_amount).toFixed(2)}
</p>
</div>
)}
</div>
))}
</div>
)}
{/* Add Venue Modal */}
{showVenueModal && (
<div className="modal-overlay-dark" onClick={() => setShowVenueModal(false)}>
<div
className="modal-content-dark"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: "550px" }}
>
<h2>{t("events.newVenue")}</h2>
<form
onSubmit={handleCreateVenue}
style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)" }}
>
<div>
<label className="label-dark">{t("events.venueName")}</label>
<input
className="input-dark"
type="text"
required
value={venueForm.name}
onChange={(e) => setVenueForm({ ...venueForm, name: e.target.value })}
/>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "var(--space-md)",
}}
>
<div>
<label className="label-dark">{t("events.capacity")}</label>
<input
className="input-dark"
type="number"
min="1"
required
value={venueForm.capacity}
onChange={(e) =>
setVenueForm({ ...venueForm, capacity: e.target.value })
}
/>
</div>
<div>
<label className="label-dark">{t("events.area")}</label>
<input
className="input-dark"
type="number"
step="0.1"
value={venueForm.area_sqm}
onChange={(e) =>
setVenueForm({ ...venueForm, area_sqm: e.target.value })
}
/>
</div>
</div>
<div>
<label className="label-dark">{t("events.pricePerHour")}</label>
<input
className="input-dark"
type="number"
step="0.01"
min="0"
required
value={venueForm.price_per_hour}
onChange={(e) =>
setVenueForm({ ...venueForm, price_per_hour: e.target.value })
}
/>
</div>
<div>
<label className="label-dark">{t("rooms.amenities")} (comma-separated)</label>
<input
className="input-dark"
type="text"
placeholder="WiFi, Projector, Sound System..."
value={venueForm.amenities}
onChange={(e) =>
setVenueForm({ ...venueForm, amenities: e.target.value })
}
/>
</div>
<div>
<label className="label-dark">{t("common.description")}</label>
<textarea
className="input-dark"
rows={2}
value={venueForm.description}
onChange={(e) =>
setVenueForm({ ...venueForm, description: e.target.value })
}
style={{ resize: "vertical" }}
/>
</div>
<div
style={{
display: "flex",
gap: "var(--space-md)",
justifyContent: "flex-end",
}}
>
<button
type="button"
className="btn-outline"
onClick={() => setShowVenueModal(false)}
>
{t("common.cancel")}
</button>
<button type="submit" className="btn-gold">
{t("common.save")}
</button>
</div>
</form>
</div>
</div>
)}
{/* New Event Modal */}
{showEventModal && (
<div className="modal-overlay-dark" onClick={() => setShowEventModal(false)}>
<div
className="modal-content-dark"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: "550px" }}
>
<h2>{t("events.newEvent")}</h2>
<form
onSubmit={handleCreateEvent}
style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)" }}
>
<div>
<label className="label-dark">{t("events.venueName")}</label>
<select
className="input-dark"
required
value={eventForm.venue_id}
onChange={(e) =>
setEventForm({ ...eventForm, venue_id: e.target.value })
}
>
<option value="">{t("events.venues")}...</option>
{venues.map((v) => (
<option key={v.id} value={v.id}>
{v.name} ({t("events.capacity")}: {v.capacity})
</option>
))}
</select>
</div>
<div>
<label className="label-dark">{t("events.eventName")}</label>
<input
className="input-dark"
type="text"
required
value={eventForm.name}
onChange={(e) =>
setEventForm({ ...eventForm, name: e.target.value })
}
/>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "var(--space-md)",
}}
>
<div>
<label className="label-dark">{t("events.organizer")}</label>
<input
className="input-dark"
type="text"
required
value={eventForm.organizer}
onChange={(e) =>
setEventForm({ ...eventForm, organizer: e.target.value })
}
/>
</div>
<div>
<label className="label-dark">{t("events.guestCount")}</label>
<input
className="input-dark"
type="number"
min="1"
value={eventForm.guest_count}
onChange={(e) =>
setEventForm({ ...eventForm, guest_count: e.target.value })
}
/>
</div>
</div>
<div>
<label className="label-dark">{t("events.eventDate")}</label>
<input
className="input-dark"
type="date"
required
value={eventForm.event_date}
onChange={(e) =>
setEventForm({ ...eventForm, event_date: e.target.value })
}
/>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "var(--space-md)",
}}
>
<div>
<label className="label-dark">{t("events.startTime")}</label>
<input
className="input-dark"
type="time"
required
value={eventForm.start_time}
onChange={(e) =>
setEventForm({ ...eventForm, start_time: e.target.value })
}
/>
</div>
<div>
<label className="label-dark">{t("events.endTime")}</label>
<input
className="input-dark"
type="time"
required
value={eventForm.end_time}
onChange={(e) =>
setEventForm({ ...eventForm, end_time: e.target.value })
}
/>
</div>
</div>
<div>
<label className="label-dark">{t("common.total")}</label>
<input
className="input-dark"
type="number"
step="0.01"
min="0"
value={eventForm.total_amount}
onChange={(e) =>
setEventForm({ ...eventForm, total_amount: e.target.value })
}
/>
</div>
<div>
<label className="label-dark">{t("common.notes")}</label>
<textarea
className="input-dark"
rows={2}
value={eventForm.notes}
onChange={(e) =>
setEventForm({ ...eventForm, notes: e.target.value })
}
style={{ resize: "vertical" }}
/>
</div>
<div
style={{
display: "flex",
gap: "var(--space-md)",
justifyContent: "flex-end",
}}
>
<button
type="button"
className="btn-outline"
onClick={() => setShowEventModal(false)}
>
{t("common.cancel")}
</button>
<button type="submit" className="btn-gold">
{t("common.save")}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,370 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
getHousekeepingTasks,
updateHousekeepingTask,
getHousekeepingStaff,
} from "../../services/housekeepingService";
export default function Housekeeping() {
const { t } = useTranslation();
const [tasks, setTasks] = useState([]);
const [staff, setStaff] = useState([]);
const [loading, setLoading] = useState(true);
const fetchData = async () => {
try {
const [tasksRes, staffRes] = await Promise.all([
getHousekeepingTasks(),
getHousekeepingStaff(),
]);
setTasks(tasksRes.data.tasks || []);
setStaff(staffRes.data.staff || []);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const pendingTasks = tasks.filter((t) => t.status === "pending");
const inProgressTasks = tasks.filter((t) => t.status === "in_progress");
const handleAssign = async (taskId, employeeId) => {
try {
await updateHousekeepingTask(taskId, { assigned_to: employeeId });
fetchData();
} catch (error) {
console.error(error);
}
};
const handleStart = async (taskId) => {
try {
await updateHousekeepingTask(taskId, { status: "in_progress" });
fetchData();
} catch (error) {
console.error(error);
}
};
const handleComplete = async (taskId) => {
try {
await updateHousekeepingTask(taskId, { status: "completed" });
fetchData();
} catch (error) {
console.error(error);
}
};
const getTimeSince = (dateStr) => {
if (!dateStr) return "";
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
};
const getPriorityColor = (priority) => {
switch (priority) {
case "high":
return "var(--status-error, #ef4444)";
case "normal":
return "var(--accent-gold, #d4a853)";
case "low":
return "var(--text-muted, #6b7280)";
default:
return "var(--text-muted, #6b7280)";
}
};
const getTypeBadgeClass = (type) => {
switch (type) {
case "checkout":
return "badge badge-info";
case "maintenance":
return "badge badge-warning";
case "deep_clean":
return "badge badge-error";
default:
return "badge";
}
};
const getTypeLabel = (type) => {
const typeMap = {
checkout: t("housekeeping.type.checkout"),
maintenance: t("housekeeping.type.maintenance"),
deep_clean: t("housekeeping.type.deepClean"),
turndown: t("housekeeping.type.turndown"),
};
return typeMap[type] || type;
};
if (loading) {
return (
<div className="empty-state">
<p>{t("common.loading")}</p>
</div>
);
}
const TaskCard = ({ task, actions }) => (
<div className="card" style={{ marginBottom: "var(--space-md)" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
<span
style={{
width: "10px",
height: "10px",
borderRadius: "50%",
backgroundColor: getPriorityColor(task.priority),
display: "inline-block",
flexShrink: 0,
}}
/>
<h3
style={{
margin: 0,
fontSize: "1rem",
color: "var(--text-primary)",
}}
>
{task.name_room || `Room ${task.room_id}`}
</h3>
{task.floor && (
<span
style={{
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
>
- {t("rooms.floor")} {task.floor}
</span>
)}
</div>
<span
style={{
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
>
{getTimeSince(task.created_at)}
</span>
</div>
<div
style={{
display: "flex",
gap: "var(--space-sm)",
marginTop: "var(--space-sm)",
flexWrap: "wrap",
}}
>
<span className={getTypeBadgeClass(task.type)}>{getTypeLabel(task.type)}</span>
<span className="badge">
{t(`housekeeping.priority.${task.priority || "normal"}`)}
</span>
</div>
{task.assigned_name && (
<p
style={{
margin: "var(--space-sm) 0 0",
fontSize: "0.85rem",
color: "var(--text-secondary)",
}}
>
{t("housekeeping.assignTo")}: {task.assigned_name}
</p>
)}
{task.notes && (
<p
style={{
margin: "var(--space-xs) 0 0",
fontSize: "0.8rem",
color: "var(--text-muted)",
}}
>
{task.notes}
</p>
)}
<div
style={{
marginTop: "var(--space-md)",
display: "flex",
gap: "var(--space-sm)",
}}
>
{actions}
</div>
</div>
);
return (
<div>
<div className="page-header">
<h2>{t("housekeeping.title")}</h2>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "var(--space-xl)",
}}
>
{/* Pending Tasks Column */}
<div>
<h3
style={{
color: "var(--text-primary)",
marginBottom: "var(--space-md)",
fontSize: "1.1rem",
}}
>
{t("housekeeping.pendingTasks")} ({pendingTasks.length})
</h3>
{pendingTasks.length === 0 ? (
<div className="empty-state">
<p>{t("common.noResults")}</p>
</div>
) : (
pendingTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
actions={
<>
{!task.assigned_to ? (
<select
className="input-dark"
style={{ fontSize: "0.85rem", padding: "6px 10px" }}
defaultValue=""
onChange={(e) => {
if (e.target.value) handleAssign(task.id, e.target.value);
}}
>
<option value="" disabled>
{t("housekeeping.assignTo")}...
</option>
{staff.map((s) => (
<option key={s.id_employee} value={s.id_employee}>
{s.first_name} {s.last_name}
</option>
))}
</select>
) : (
<button
className="btn-success"
style={{ fontSize: "0.85rem", padding: "6px 16px" }}
onClick={() => handleStart(task.id)}
>
{t("housekeeping.startTask")}
</button>
)}
</>
}
/>
))
)}
</div>
{/* In Progress Column */}
<div>
<h3
style={{
color: "var(--text-primary)",
marginBottom: "var(--space-md)",
fontSize: "1.1rem",
}}
>
{t("housekeeping.inProgress")} ({inProgressTasks.length})
</h3>
{inProgressTasks.length === 0 ? (
<div className="empty-state">
<p>{t("common.noResults")}</p>
</div>
) : (
inProgressTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
actions={
<button
className="btn-gold"
style={{ fontSize: "0.85rem", padding: "6px 16px" }}
onClick={() => handleComplete(task.id)}
>
{t("housekeeping.completeTask")}
</button>
}
/>
))
)}
</div>
</div>
{/* Staff Panel */}
<div style={{ marginTop: "var(--space-xl)" }}>
<h3
style={{
color: "var(--text-primary)",
marginBottom: "var(--space-md)",
fontSize: "1.1rem",
}}
>
{t("housekeeping.staffAvailability")}
</h3>
<div className="grid-3">
{staff.map((s) => (
<div key={s.id_employee} className="card">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<p
style={{
margin: 0,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{s.first_name} {s.last_name}
</p>
</div>
<span
className={
parseInt(s.active_tasks) > 0
? "badge badge-warning"
: "badge badge-success"
}
>
{s.active_tasks || 0} active
</span>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,267 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
getMenu,
createMenuItem,
updateMenuItem,
} from "../../services/roomServiceService";
export default function MenuManager() {
const { t } = useTranslation();
const [menuItems, setMenuItems] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [form, setForm] = useState({
name: "",
name_es: "",
description: "",
price: "",
category: "",
});
const fetchMenu = async () => {
try {
const res = await getMenu();
setMenuItems(res.data.menu || []);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMenu();
}, []);
const handleCreate = async (e) => {
e.preventDefault();
try {
await createMenuItem({
...form,
price: parseFloat(form.price),
});
setShowModal(false);
setForm({ name: "", name_es: "", description: "", price: "", category: "" });
fetchMenu();
} catch (error) {
console.error(error);
}
};
const handleToggleAvailable = async (item) => {
try {
await updateMenuItem(item.id, { available: !item.available });
fetchMenu();
} catch (error) {
console.error(error);
}
};
if (loading) {
return (
<div className="empty-state">
<p>{t("common.loading")}</p>
</div>
);
}
return (
<div>
<div className="page-header">
<h2>{t("roomService.menuManagement")}</h2>
<button className="btn-gold" onClick={() => setShowModal(true)}>
{t("roomService.addItem")}
</button>
</div>
<div style={{ overflowX: "auto" }}>
<table className="table-dark">
<thead>
<tr>
<th>{t("common.name")}</th>
<th>{t("common.description")}</th>
<th>{t("common.price")}</th>
<th>{t("common.category")}</th>
<th>{t("common.status")}</th>
</tr>
</thead>
<tbody>
{menuItems.map((item) => (
<tr key={item.id}>
<td>
<div>
<span style={{ color: "var(--text-primary)", fontWeight: 500 }}>
{item.name}
</span>
{item.name_es && (
<span
style={{
display: "block",
fontSize: "0.8rem",
color: "var(--text-muted)",
}}
>
{item.name_es}
</span>
)}
</div>
</td>
<td style={{ color: "var(--text-secondary)", fontSize: "0.85rem" }}>
{item.description || "-"}
</td>
<td style={{ color: "var(--accent-gold, #d4a853)", fontWeight: 600 }}>
${parseFloat(item.price || 0).toFixed(2)}
</td>
<td>
<span className="badge">{item.category}</span>
</td>
<td>
<label
style={{
display: "flex",
alignItems: "center",
gap: "var(--space-sm)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={item.available !== false}
onChange={() => handleToggleAvailable(item)}
style={{
width: "18px",
height: "18px",
cursor: "pointer",
accentColor: "var(--accent-gold, #d4a853)",
}}
/>
<span
style={{
fontSize: "0.85rem",
color: item.available !== false
? "var(--status-success, #22c55e)"
: "var(--text-muted)",
}}
>
{item.available !== false ? "Active" : "Inactive"}
</span>
</label>
</td>
</tr>
))}
</tbody>
</table>
</div>
{menuItems.length === 0 && (
<div className="empty-state">
<p>{t("common.noResults")}</p>
</div>
)}
{/* Add Item Modal */}
{showModal && (
<div className="modal-overlay-dark" onClick={() => setShowModal(false)}>
<div
className="modal-content-dark"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: "500px" }}
>
<h2>{t("roomService.addItem")}</h2>
<form
onSubmit={handleCreate}
style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)" }}
>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "var(--space-md)",
}}
>
<div>
<label className="label-dark">{t("common.name")} (EN)</label>
<input
className="input-dark"
type="text"
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div>
<label className="label-dark">{t("common.name")} (ES)</label>
<input
className="input-dark"
type="text"
value={form.name_es}
onChange={(e) => setForm({ ...form, name_es: e.target.value })}
/>
</div>
</div>
<div>
<label className="label-dark">{t("common.description")}</label>
<textarea
className="input-dark"
rows={2}
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
style={{ resize: "vertical" }}
/>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "var(--space-md)",
}}
>
<div>
<label className="label-dark">{t("common.price")}</label>
<input
className="input-dark"
type="number"
step="0.01"
min="0"
required
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
/>
</div>
<div>
<label className="label-dark">{t("common.category")}</label>
<input
className="input-dark"
type="text"
required
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
/>
</div>
</div>
<div
style={{
display: "flex",
gap: "var(--space-md)",
justifyContent: "flex-end",
}}
>
<button
type="button"
className="btn-outline"
onClick={() => setShowModal(false)}
>
{t("common.cancel")}
</button>
<button type="submit" className="btn-gold">
{t("common.save")}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,384 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { getRoomsWithStatus } from "../../services/roomService";
import { getMenu, createRoomServiceOrder } from "../../services/roomServiceService";
export default function NewOrder() {
const { t } = useTranslation();
const navigate = useNavigate();
const [rooms, setRooms] = useState([]);
const [menuItems, setMenuItems] = useState([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [selectedRoom, setSelectedRoom] = useState("");
const [orderItems, setOrderItems] = useState([]);
const [notes, setNotes] = useState("");
useEffect(() => {
const fetchData = async () => {
try {
const [roomsRes, menuRes] = await Promise.all([
getRoomsWithStatus(),
getMenu(),
]);
const occupiedRooms = (roomsRes.data.rooms || []).filter(
(r) => r.status === "occupied"
);
setRooms(occupiedRooms);
const availableMenu = (menuRes.data.menu || []).filter(
(item) => item.available !== false
);
setMenuItems(availableMenu);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const handleAddItem = (menuItem) => {
const existing = orderItems.find((oi) => oi.menu_item_id === menuItem.id);
if (existing) {
setOrderItems(
orderItems.map((oi) =>
oi.menu_item_id === menuItem.id
? { ...oi, quantity: oi.quantity + 1 }
: oi
)
);
} else {
setOrderItems([
...orderItems,
{
menu_item_id: menuItem.id,
name: menuItem.name,
price: parseFloat(menuItem.price),
quantity: 1,
},
]);
}
};
const handleQuantityChange = (menuItemId, quantity) => {
if (quantity <= 0) {
setOrderItems(orderItems.filter((oi) => oi.menu_item_id !== menuItemId));
} else {
setOrderItems(
orderItems.map((oi) =>
oi.menu_item_id === menuItemId ? { ...oi, quantity } : oi
)
);
}
};
const handleRemoveItem = (menuItemId) => {
setOrderItems(orderItems.filter((oi) => oi.menu_item_id !== menuItemId));
};
const total = orderItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
const handleSubmit = async (e) => {
e.preventDefault();
if (!selectedRoom || orderItems.length === 0) return;
setSubmitting(true);
try {
await createRoomServiceOrder({
room_id: selectedRoom,
items: orderItems.map((oi) => ({
menu_item_id: oi.menu_item_id,
quantity: oi.quantity,
})),
notes: notes || null,
});
navigate("/app/room-service");
} catch (error) {
console.error(error);
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div className="empty-state">
<p>{t("common.loading")}</p>
</div>
);
}
// Group menu items by category
const categories = {};
menuItems.forEach((item) => {
const cat = item.category || "Other";
if (!categories[cat]) categories[cat] = [];
categories[cat].push(item);
});
return (
<div>
<div className="page-header">
<h2>{t("roomService.newOrder")}</h2>
<button className="btn-outline" onClick={() => navigate("/app/room-service")}>
{t("common.back")}
</button>
</div>
<form onSubmit={handleSubmit}>
<div
style={{
display: "grid",
gridTemplateColumns: "2fr 1fr",
gap: "var(--space-xl)",
}}
>
{/* Left: Menu Items */}
<div>
<div className="card" style={{ marginBottom: "var(--space-lg)" }}>
<label className="label-dark">{t("common.room")}</label>
<select
className="input-dark"
value={selectedRoom}
onChange={(e) => setSelectedRoom(e.target.value)}
required
>
<option value="">{t("common.room")}...</option>
{rooms.map((room) => (
<option key={room.id_room} value={room.id_room}>
{room.name_room} - {t("rooms.floor")} {room.floor}
</option>
))}
</select>
</div>
{Object.entries(categories).map(([category, items]) => (
<div key={category} style={{ marginBottom: "var(--space-lg)" }}>
<h3
style={{
color: "var(--text-primary)",
marginBottom: "var(--space-sm)",
fontSize: "1rem",
}}
>
{category}
</h3>
<div className="grid-2">
{items.map((item) => {
const inOrder = orderItems.find(
(oi) => oi.menu_item_id === item.id
);
return (
<div
key={item.id}
className="card"
style={{
cursor: "pointer",
border: inOrder
? "1px solid var(--accent-gold, #d4a853)"
: undefined,
}}
onClick={() => handleAddItem(item)}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<p
style={{
margin: 0,
fontWeight: 500,
color: "var(--text-primary)",
fontSize: "0.9rem",
}}
>
{item.name}
</p>
{item.description && (
<p
style={{
margin: "2px 0 0",
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
>
{item.description}
</p>
)}
</div>
<span
style={{
fontWeight: 600,
color: "var(--accent-gold, #d4a853)",
whiteSpace: "nowrap",
}}
>
${parseFloat(item.price).toFixed(2)}
</span>
</div>
{inOrder && (
<div
style={{
marginTop: "var(--space-xs)",
fontSize: "0.8rem",
color: "var(--accent-gold, #d4a853)",
}}
>
{t("common.quantity")}: {inOrder.quantity}
</div>
)}
</div>
);
})}
</div>
</div>
))}
</div>
{/* Right: Order Summary */}
<div>
<div
className="card"
style={{ position: "sticky", top: "var(--space-lg)" }}
>
<h3
style={{
margin: "0 0 var(--space-md)",
color: "var(--text-primary)",
}}
>
{t("roomService.orderTotal")}
</h3>
{orderItems.length === 0 ? (
<p
style={{
color: "var(--text-muted)",
fontSize: "0.85rem",
textAlign: "center",
padding: "var(--space-lg) 0",
}}
>
{t("common.noResults")}
</p>
) : (
<div>
{orderItems.map((item) => (
<div
key={item.menu_item_id}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "var(--space-sm) 0",
borderBottom: "1px solid var(--border-color, #333)",
}}
>
<div style={{ flex: 1 }}>
<p
style={{
margin: 0,
fontSize: "0.85rem",
color: "var(--text-primary)",
}}
>
{item.name}
</p>
<p
style={{
margin: 0,
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
>
${item.price.toFixed(2)} x {item.quantity}
</p>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: "var(--space-xs)",
}}
>
<input
type="number"
min="1"
value={item.quantity}
onChange={(e) =>
handleQuantityChange(
item.menu_item_id,
parseInt(e.target.value) || 0
)
}
onClick={(e) => e.stopPropagation()}
className="input-dark"
style={{ width: "60px", textAlign: "center", padding: "4px" }}
/>
<button
type="button"
onClick={() => handleRemoveItem(item.menu_item_id)}
style={{
background: "none",
border: "none",
color: "var(--status-error, #ef4444)",
cursor: "pointer",
fontSize: "1.1rem",
padding: "4px",
}}
>
X
</button>
</div>
</div>
))}
<div
style={{
display: "flex",
justifyContent: "space-between",
padding: "var(--space-md) 0",
fontWeight: 700,
fontSize: "1.1rem",
color: "var(--accent-gold, #d4a853)",
}}
>
<span>{t("common.total")}</span>
<span>${total.toFixed(2)}</span>
</div>
</div>
)}
<div style={{ marginTop: "var(--space-md)" }}>
<label className="label-dark">{t("common.notes")}</label>
<textarea
className="input-dark"
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
style={{ resize: "vertical" }}
/>
</div>
<button
type="submit"
className="btn-gold"
disabled={!selectedRoom || orderItems.length === 0 || submitting}
style={{ width: "100%", marginTop: "var(--space-md)" }}
>
{submitting ? t("common.loading") : t("roomService.newOrder")}
</button>
</div>
</div>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,226 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
getRoomServiceOrders,
updateRoomServiceOrderStatus,
} from "../../services/roomServiceService";
export default function RoomServiceOrders() {
const { t } = useTranslation();
const navigate = useNavigate();
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const fetchOrders = async () => {
try {
const res = await getRoomServiceOrders();
setOrders(res.data.orders || []);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchOrders();
}, []);
const handleStatusUpdate = async (orderId, newStatus) => {
try {
await updateRoomServiceOrderStatus(orderId, newStatus);
fetchOrders();
} catch (error) {
console.error(error);
}
};
const getTimeSince = (dateStr) => {
if (!dateStr) return "";
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
};
const getStatusBadgeClass = (status) => {
switch (status) {
case "pending":
return "badge badge-warning";
case "preparing":
return "badge badge-info";
case "delivering":
return "badge badge-info";
case "delivered":
return "badge badge-success";
case "cancelled":
return "badge badge-error";
default:
return "badge";
}
};
const getNextAction = (status) => {
switch (status) {
case "pending":
return { label: t("roomService.status.preparing"), next: "preparing", className: "btn-gold" };
case "preparing":
return { label: t("roomService.status.delivering"), next: "delivering", className: "btn-gold" };
case "delivering":
return { label: t("roomService.status.delivered"), next: "delivered", className: "btn-success" };
default:
return null;
}
};
if (loading) {
return (
<div className="empty-state">
<p>{t("common.loading")}</p>
</div>
);
}
return (
<div>
<div className="page-header">
<h2>{t("roomService.title")}</h2>
<div style={{ display: "flex", gap: "var(--space-sm)" }}>
<button
className="btn-outline"
onClick={() => navigate("/app/room-service/menu")}
>
{t("roomService.menuManagement")}
</button>
<button
className="btn-gold"
onClick={() => navigate("/app/room-service/new-order")}
>
{t("roomService.newOrder")}
</button>
</div>
</div>
{orders.length === 0 ? (
<div className="empty-state">
<p>{t("common.noResults")}</p>
</div>
) : (
<div className="grid-3">
{orders.map((order) => {
const action = getNextAction(order.status);
const items = Array.isArray(order.items) ? order.items.filter((i) => i.id !== null) : [];
return (
<div key={order.id} className="card">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
<div>
<h3
style={{
margin: 0,
fontSize: "1rem",
color: "var(--text-primary)",
}}
>
{order.name_room || `Room ${order.room_id}`}
</h3>
{order.guest_name && (
<p
style={{
margin: "2px 0 0",
fontSize: "0.85rem",
color: "var(--text-secondary)",
}}
>
{order.guest_name}
</p>
)}
</div>
<span
style={{
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
>
{getTimeSince(order.created_at)}
</span>
</div>
<div style={{ margin: "var(--space-sm) 0" }}>
<span className={getStatusBadgeClass(order.status)}>
{t(`roomService.status.${order.status}`)}
</span>
</div>
{items.length > 0 && (
<div
style={{
borderTop: "1px solid var(--border-color, #333)",
paddingTop: "var(--space-sm)",
marginTop: "var(--space-sm)",
}}
>
{items.map((item, idx) => (
<div
key={idx}
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.85rem",
color: "var(--text-secondary)",
padding: "2px 0",
}}
>
<span>
{item.quantity}x {item.name}
</span>
<span>${parseFloat(item.price || 0).toFixed(2)}</span>
</div>
))}
</div>
)}
<div
style={{
borderTop: "1px solid var(--border-color, #333)",
paddingTop: "var(--space-sm)",
marginTop: "var(--space-sm)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span
style={{
fontWeight: 700,
color: "var(--accent-gold, #d4a853)",
}}
>
{t("common.total")}: ${parseFloat(order.total || 0).toFixed(2)}
</span>
{action && (
<button
className={action.className}
style={{ fontSize: "0.8rem", padding: "4px 12px" }}
onClick={() => handleStatusUpdate(order.id, action.next)}
>
{action.label}
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,7 @@
import api from "./api";
export const getVenues = () => api.get("/events/venues");
export const createVenue = (data) => api.post("/events/venues", data);
export const updateVenue = (id, data) => api.put(`/events/venues/${id}`, data);
export const getEvents = (params) => api.get("/events", { params });
export const createEvent = (data) => api.post("/events", data);
export const updateEvent = (id, data) => api.put(`/events/${id}`, data);

View File

@@ -0,0 +1,5 @@
import api from "./api";
export const getHousekeepingTasks = (params) => api.get("/housekeeping/tasks", { params });
export const createHousekeepingTask = (data) => api.post("/housekeeping/tasks", data);
export const updateHousekeepingTask = (id, data) => api.put(`/housekeeping/tasks/${id}`, data);
export const getHousekeepingStaff = () => api.get("/housekeeping/staff");

View File

@@ -0,0 +1,7 @@
import api from "./api";
export const getRoomServiceOrders = (params) => api.get("/room-service/orders", { params });
export const createRoomServiceOrder = (data) => api.post("/room-service/orders", data);
export const updateRoomServiceOrderStatus = (id, status) => api.put(`/room-service/orders/${id}/status`, { status });
export const getMenu = () => api.get("/room-service/menu");
export const createMenuItem = (data) => api.post("/room-service/menu", data);
export const updateMenuItem = (id, data) => api.put(`/room-service/menu/${id}`, data);