feat: add housekeeping, room service, and events modules
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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",
|
||||
|
||||
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