feat: add housekeeping, room service, and events modules
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,9 @@ const roomsRoutes = require('./routes/rooms.routes');
|
|||||||
const dashboardRoutes = require('./routes/dashboard.routes');
|
const dashboardRoutes = require('./routes/dashboard.routes');
|
||||||
const reservationsRoutes = require('./routes/reservations.routes');
|
const reservationsRoutes = require('./routes/reservations.routes');
|
||||||
const guestsRoutes = require('./routes/guests.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)
|
//Prefijo - Auth routes are public (no middleware)
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
@@ -62,5 +65,8 @@ app.use('/api/rooms', authMiddleware, roomsRoutes);
|
|||||||
app.use('/api/dashboard', authMiddleware, dashboardRoutes);
|
app.use('/api/dashboard', authMiddleware, dashboardRoutes);
|
||||||
app.use('/api/reservations', authMiddleware, reservationsRoutes);
|
app.use('/api/reservations', authMiddleware, reservationsRoutes);
|
||||||
app.use('/api/guests', authMiddleware, guestsRoutes);
|
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;
|
module.exports = app;
|
||||||
|
|||||||
111
backend/hotel_hacienda/src/controllers/events.controller.js
Normal file
111
backend/hotel_hacienda/src/controllers/events.controller.js
Normal 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 };
|
||||||
@@ -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 };
|
||||||
130
backend/hotel_hacienda/src/controllers/roomservice.controller.js
Normal file
130
backend/hotel_hacienda/src/controllers/roomservice.controller.js
Normal 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 };
|
||||||
12
backend/hotel_hacienda/src/routes/events.routes.js
Normal file
12
backend/hotel_hacienda/src/routes/events.routes.js
Normal 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;
|
||||||
10
backend/hotel_hacienda/src/routes/housekeeping.routes.js
Normal file
10
backend/hotel_hacienda/src/routes/housekeeping.routes.js
Normal 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;
|
||||||
12
backend/hotel_hacienda/src/routes/roomservice.routes.js
Normal file
12
backend/hotel_hacienda/src/routes/roomservice.routes.js
Normal 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;
|
||||||
@@ -44,6 +44,11 @@ import Reservations from "./pages/Reservations/Reservations.jsx";
|
|||||||
import NewReservation from "./pages/Reservations/NewReservation.jsx";
|
import NewReservation from "./pages/Reservations/NewReservation.jsx";
|
||||||
import Guests from "./pages/Guests/Guests.jsx";
|
import Guests from "./pages/Guests/Guests.jsx";
|
||||||
import GuestDetail from "./pages/Guests/GuestDetail.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";
|
import "./styles/global.css";
|
||||||
//Submenú de Hotel
|
//Submenú de Hotel
|
||||||
@@ -141,6 +146,11 @@ export default function App() {
|
|||||||
<Route path="reservations/new" element={<NewReservation />} />
|
<Route path="reservations/new" element={<NewReservation />} />
|
||||||
<Route path="guests" element={<Guests />} />
|
<Route path="guests" element={<Guests />} />
|
||||||
<Route path="guests/:id" element={<GuestDetail />} />
|
<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>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -91,6 +91,16 @@ export const menuConfig = {
|
|||||||
{ label: "Contracts", spanish_label: "Contratos", route: "/app/payroll/contract" }
|
{ 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: {
|
// hotel: {
|
||||||
// label: "Hotel",
|
// label: "Hotel",
|
||||||
// spanish_label: "Hotel",
|
// spanish_label: "Hotel",
|
||||||
|
|||||||
761
frontend/Frontend-Hotel/src/pages/Events/EventsVenues.jsx
Normal file
761
frontend/Frontend-Hotel/src/pages/Events/EventsVenues.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
370
frontend/Frontend-Hotel/src/pages/Housekeeping/Housekeeping.jsx
Normal file
370
frontend/Frontend-Hotel/src/pages/Housekeeping/Housekeeping.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
frontend/Frontend-Hotel/src/pages/RoomService/MenuManager.jsx
Normal file
267
frontend/Frontend-Hotel/src/pages/RoomService/MenuManager.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
384
frontend/Frontend-Hotel/src/pages/RoomService/NewOrder.jsx
Normal file
384
frontend/Frontend-Hotel/src/pages/RoomService/NewOrder.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend/Frontend-Hotel/src/services/eventService.js
Normal file
7
frontend/Frontend-Hotel/src/services/eventService.js
Normal 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);
|
||||||
@@ -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");
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user