This commit is contained in:
Iliyan Angelov
2025-12-05 17:43:03 +02:00
parent e1988fe37a
commit 13c91f95f4
51 changed files with 11933 additions and 289 deletions

View File

@@ -66,6 +66,7 @@ const ComplaintPage = lazy(() => import('./pages/customer/ComplaintPage'));
const GuestRequestsPage = lazy(() => import('./pages/customer/GuestRequestsPage'));
const GDPRPage = lazy(() => import('./pages/customer/GDPRPage'));
const GDPRDeletionConfirmPage = lazy(() => import('./pages/customer/GDPRDeletionConfirmPage'));
const SessionManagementPage = lazy(() => import('./pages/customer/SessionManagementPage'));
const AboutPage = lazy(() => import('./features/content/pages/AboutPage'));
const ContactPage = lazy(() => import('./features/content/pages/ContactPage'));
const PrivacyPolicyPage = lazy(() => import('./features/content/pages/PrivacyPolicyPage'));
@@ -112,13 +113,18 @@ const WebhookManagementPage = lazy(() => import('./pages/admin/WebhookManagement
const APIKeyManagementPage = lazy(() => import('./pages/admin/APIKeyManagementPage'));
const BackupManagementPage = lazy(() => import('./pages/admin/BackupManagementPage'));
const ServiceManagementPage = lazy(() => import('./pages/admin/ServiceManagementPage'));
const InventoryManagementPage = lazy(() => import('./pages/admin/InventoryManagementPage'));
const MaintenanceManagementPage = lazy(() => import('./pages/admin/MaintenanceManagementPage'));
const InspectionManagementPage = lazy(() => import('./pages/admin/InspectionManagementPage'));
const StaffShiftManagementPage = lazy(() => import('./pages/admin/StaffShiftManagementPage'));
const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage'));
const StaffInventoryViewPage = lazy(() => import('./pages/staff/InventoryViewPage'));
const StaffShiftViewPage = lazy(() => import('./pages/staff/ShiftViewPage'));
const StaffBookingManagementPage = lazy(() => import('./pages/staff/BookingManagementPage'));
const StaffReceptionDashboardPage = lazy(() => import('./pages/staff/ReceptionDashboardPage'));
const StaffPaymentManagementPage = lazy(() => import('./pages/staff/PaymentManagementPage'));
const StaffAnalyticsDashboardPage = lazy(() => import('./pages/staff/AnalyticsDashboardPage'));
const StaffLoyaltyManagementPage = lazy(() => import('./pages/staff/LoyaltyManagementPage'));
const StaffGuestProfilePage = lazy(() => import('./pages/staff/GuestProfilePage'));
const StaffAdvancedRoomManagementPage = lazy(() => import('./pages/staff/AdvancedRoomManagementPage'));
const ChatManagementPage = lazy(() => import('./pages/staff/ChatManagementPage'));
@@ -142,6 +148,7 @@ const AccountantLayout = lazy(() => import('./pages/AccountantLayout'));
const HousekeepingDashboardPage = lazy(() => import('./pages/housekeeping/DashboardPage'));
const HousekeepingTasksPage = lazy(() => import('./pages/housekeeping/TasksPage'));
const HousekeepingShiftViewPage = lazy(() => import('./pages/housekeeping/ShiftViewPage'));
const HousekeepingLayout = lazy(() => import('./pages/HousekeepingLayout'));
const AdminProfilePage = lazy(() => import('./pages/admin/ProfilePage'));
@@ -502,6 +509,16 @@ function App() {
</ErrorBoundaryRoute>
}
/>
<Route
path="sessions"
element={
<ErrorBoundaryRoute>
<CustomerRoute>
<SessionManagementPage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
</Route>
{/* Separate Login Pages for Each Role */}
@@ -696,10 +713,30 @@ function App() {
path="services"
element={<ServiceManagementPage />}
/>
<Route
path="inventory"
element={<InventoryManagementPage />}
/>
<Route
path="maintenance"
element={<MaintenanceManagementPage />}
/>
<Route
path="inspections"
element={<InspectionManagementPage />}
/>
<Route
path="shifts"
element={<StaffShiftManagementPage />}
/>
<Route
path="profile"
element={<AdminProfilePage />}
/>
<Route
path="sessions"
element={<SessionManagementPage />}
/>
</Route>
{}
@@ -738,10 +775,6 @@ function App() {
path="chats"
element={<ChatManagementPage />}
/>
<Route
path="loyalty"
element={<StaffLoyaltyManagementPage />}
/>
<Route
path="guest-profiles"
element={<StaffGuestProfilePage />}
@@ -774,6 +807,18 @@ function App() {
path="profile"
element={<StaffProfilePage />}
/>
<Route
path="sessions"
element={<SessionManagementPage />}
/>
<Route
path="inventory"
element={<StaffInventoryViewPage />}
/>
<Route
path="shifts"
element={<StaffShiftViewPage />}
/>
</Route>
{/* Accountant Routes */}
@@ -828,6 +873,10 @@ function App() {
path="profile"
element={<AccountantProfilePage />}
/>
<Route
path="sessions"
element={<SessionManagementPage />}
/>
<Route
path="invoices/:id/edit"
element={<InvoiceEditPage />}
@@ -854,6 +903,9 @@ function App() {
element={<Navigate to="dashboard" replace />}
/>
<Route path="dashboard" element={<HousekeepingDashboardPage />} />
<Route path="tasks" element={<HousekeepingTasksPage />} />
<Route path="shifts" element={<HousekeepingShiftViewPage />} />
<Route path="sessions" element={<SessionManagementPage />} />
</Route>
{}

View File

@@ -21,6 +21,7 @@ import bannerService from '../services/bannerService';
import roomService from '../../rooms/services/roomService';
import pageContentService from '../services/pageContentService';
import serviceService from '../../hotel_services/services/serviceService';
import blogService, { BlogPost } from '../services/blogService';
import { useFormatCurrency } from '../../payments/hooks/useFormatCurrency';
import type { Banner } from '../services/bannerService';
import type { Room } from '../../rooms/services/roomService';
@@ -34,12 +35,14 @@ const HomePage: React.FC = () => {
const [newestRooms, setNewestRooms] = useState<Room[]>([]);
const [pageContent, setPageContent] = useState<PageContent | null>(null);
const [services, setServices] = useState<Service[]>([]);
const [blogPosts, setBlogPosts] = useState<BlogPost[]>([]);
const [isLoadingBanners, setIsLoadingBanners] =
useState(true);
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
const [, setIsLoadingContent] = useState(true);
const [isLoadingServices, setIsLoadingServices] = useState(true);
const [isLoadingBlog, setIsLoadingBlog] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiError, setApiError] = useState(false);
const [apiErrorMessage, setApiErrorMessage] = useState<string>('');
@@ -153,16 +156,6 @@ const HomePage: React.FC = () => {
} else if (!Array.isArray(content.amenities)) {
content.amenities = content.amenities || [];
}
// Handle testimonials - can be string, array, or null/undefined
if (typeof content.testimonials === 'string') {
try {
content.testimonials = JSON.parse(content.testimonials);
} catch (e) {
content.testimonials = [];
}
} else if (!Array.isArray(content.testimonials)) {
content.testimonials = content.testimonials || [];
}
// Handle gallery_images - can be string, array, or null/undefined
if (typeof content.gallery_images === 'string') {
try {
@@ -258,6 +251,53 @@ const HomePage: React.FC = () => {
} else if (!Array.isArray(content.partners)) {
content.partners = content.partners || [];
}
// Handle trust_badges - can be string, array, or null/undefined
if (typeof content.trust_badges === 'string') {
try {
content.trust_badges = JSON.parse(content.trust_badges);
} catch (e) {
content.trust_badges = [];
}
} else if (!Array.isArray(content.trust_badges)) {
content.trust_badges = content.trust_badges || [];
}
// Handle promotions - can be string, array, or null/undefined
if (typeof content.promotions === 'string') {
try {
content.promotions = JSON.parse(content.promotions);
} catch (e) {
content.promotions = [];
}
} else if (!Array.isArray(content.promotions)) {
content.promotions = content.promotions || [];
}
// Handle sections_enabled - can be string, object, or null/undefined
if (typeof content.sections_enabled === 'string') {
try {
content.sections_enabled = JSON.parse(content.sections_enabled);
} catch (e) {
content.sections_enabled = {};
}
} else if (typeof content.sections_enabled !== 'object' || content.sections_enabled === null) {
content.sections_enabled = content.sections_enabled || {};
}
// Normalize boolean values (MySQL returns 1/0, convert to true/false)
if (content.newsletter_enabled !== undefined) {
content.newsletter_enabled = Boolean(content.newsletter_enabled);
}
if (content.trust_badges_enabled !== undefined) {
content.trust_badges_enabled = Boolean(content.trust_badges_enabled);
}
if (content.promotions_enabled !== undefined) {
content.promotions_enabled = Boolean(content.promotions_enabled);
}
if (content.blog_enabled !== undefined) {
content.blog_enabled = Boolean(content.blog_enabled);
}
if (content.rooms_section_enabled !== undefined) {
content.rooms_section_enabled = Boolean(content.rooms_section_enabled);
}
setPageContent(content);
@@ -290,27 +330,53 @@ const HomePage: React.FC = () => {
fetchPageContent();
}, []);
useEffect(() => {
const fetchBlogPosts = async () => {
if (!pageContent || !pageContent.blog_enabled || (pageContent.sections_enabled?.blog === false)) {
return;
}
try {
setIsLoadingBlog(true);
const limit = pageContent.blog_posts_limit || 3;
const response = await blogService.getBlogPosts({
published_only: true,
limit: limit,
page: 1,
});
if (response.status === 'success' && response.data?.posts) {
setBlogPosts(response.data.posts);
}
} catch (error: any) {
console.error('Error fetching blog posts:', error);
} finally {
setIsLoadingBlog(false);
}
};
if (pageContent) {
fetchBlogPosts();
}
}, [pageContent?.blog_enabled, pageContent?.sections_enabled?.blog, pageContent?.blog_posts_limit]);
useEffect(() => {
const fetchBanners = async () => {
try {
setIsLoadingBanners(true);
const response = await bannerService
.getBannersByPosition('home');
const response = await bannerService.getBannersByPosition('home');
if (
response.success ||
response.status === 'success'
) {
setBanners(response.data?.banners || []);
if (response.success || response.status === 'success') {
const fetchedBanners = response.data?.banners || [];
setBanners(fetchedBanners);
if (fetchedBanners.length === 0) {
console.log('No banners found for home position');
}
} else {
console.warn('Banner service returned unsuccessful response:', response);
setBanners([]);
}
} catch (err: any) {
console.error('Error fetching banners:', err);
setApiError(true);
setApiErrorMessage('Unable to connect to the server. Please check your internet connection and try again.');
// Don't set API error for banners - it's not critical
setBanners([]);
} finally {
setIsLoadingBanners(false);
@@ -477,6 +543,69 @@ const HomePage: React.FC = () => {
<SearchRoomForm className="overlay" />
</BannerCarousel>
</div>
) : pageContent ? (
(() => {
// Check for valid local video first
const videoUrl = pageContent.hero_video_url;
if (videoUrl && (
videoUrl.startsWith('/') ||
videoUrl.startsWith('http://localhost') ||
videoUrl.startsWith('https://localhost') ||
videoUrl.startsWith(window.location.origin)
)) {
return (
<div className="relative w-full h-[600px] md:h-[700px] lg:h-[800px] overflow-hidden">
<video
autoPlay
loop
muted
playsInline
poster={pageContent.hero_video_poster || pageContent.hero_image || ''}
className="absolute inset-0 w-full h-full object-cover"
onError={(e) => {
console.warn('Video failed to load, using fallback image');
const videoElement = e.target as HTMLVideoElement;
videoElement.style.display = 'none';
}}
>
<source src={pageContent.hero_video_url} type="video/mp4" />
Your browser does not support the video tag.
</video>
{(pageContent.hero_video_poster || pageContent.hero_image) && (
<img
src={pageContent.hero_video_poster || pageContent.hero_image || ''}
alt="Hero background"
className="absolute inset-0 w-full h-full object-cover"
style={{ zIndex: -1 }}
/>
)}
<div className="absolute inset-0 bg-black/40"></div>
<div className="absolute inset-0 flex items-center justify-center z-10">
<SearchRoomForm className="overlay" />
</div>
</div>
);
}
// Check for hero image as fallback
if (pageContent.hero_image) {
return (
<div className="relative w-full h-[600px] md:h-[700px] lg:h-[800px] overflow-hidden">
<img
src={pageContent.hero_image}
alt="Hero background"
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/40"></div>
<div className="absolute inset-0 flex items-center justify-center z-10">
<SearchRoomForm className="overlay" />
</div>
</div>
);
}
return null;
})()
) : null}
</section>
@@ -486,6 +615,7 @@ const HomePage: React.FC = () => {
<div className="relative z-10">
{}
{(pageContent?.sections_enabled?.rooms !== false) && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 md:py-12 lg:py-16">
{}
<div className="text-center animate-fade-in mb-6 md:mb-8">
@@ -493,20 +623,20 @@ const HomePage: React.FC = () => {
<div className="h-0.5 w-16 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
</div>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-3 md:mb-4">
{pageContent?.hero_title || (apiError ? '' : 'Featured & Newest Rooms')}
{pageContent?.rooms_section_title || pageContent?.hero_title || (apiError ? '' : 'Featured & Newest Rooms')}
</h2>
<p className="text-sm sm:text-base md:text-lg text-gray-600 font-light tracking-wide max-w-2xl mx-auto px-4 leading-relaxed">
{pageContent?.hero_subtitle || pageContent?.description || (apiError ? '' : 'Discover our most popular accommodations and latest additions')}
{pageContent?.rooms_section_subtitle || pageContent?.hero_subtitle || pageContent?.description || (apiError ? '' : 'Discover our most popular accommodations and latest additions')}
</p>
{}
<div className="mt-6 md:mt-8 flex justify-center">
<Link
to="/rooms"
to={pageContent?.rooms_section_button_link || '/rooms'}
className="group relative inline-flex items-center gap-2 px-6 py-2.5 md:px-8 md:py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg font-semibold tracking-wide text-sm md:text-base shadow-md shadow-[#d4af37]/20 hover:shadow-lg hover:shadow-[#d4af37]/30 hover:-translate-y-0.5 transition-all duration-300 overflow-hidden"
>
<span className="absolute inset-0 bg-gradient-to-r from-[#f5d76e] to-[#d4af37] opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
<span className="relative z-10">View All Rooms</span>
<span className="relative z-10">{pageContent?.rooms_section_button_text || 'View All Rooms'}</span>
<ArrowRight className="w-4 h-4 md:w-5 md:h-5 relative z-10 group-hover:translate-x-1 transition-transform duration-300" />
</Link>
</div>
@@ -571,9 +701,10 @@ const HomePage: React.FC = () => {
</>
)}
</section>
)}
{}
{(() => {
{(pageContent?.sections_enabled?.features !== false) && (() => {
const validFeatures = pageContent?.features?.filter(
(f: any) => f && (f.title || f.description)
@@ -590,6 +721,24 @@ const HomePage: React.FC = () => {
{}
<div className="absolute inset-0 opacity-[0.015] bg-[radial-gradient(circle_at_1px_1px,#d4af37_1px,transparent_0)] bg-[length:40px_40px]"></div>
{(pageContent?.features_section_title || pageContent?.features_section_subtitle) && (
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
<div className="h-0.5 w-16 md:w-20 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
</div>
{pageContent.features_section_title && (
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-3 md:mb-4 px-4">
{pageContent.features_section_title}
</h2>
)}
{pageContent.features_section_subtitle && (
<p className="text-sm sm:text-base md:text-lg text-gray-600 font-light tracking-wide max-w-2xl mx-auto mt-3 md:mt-4 px-4 leading-relaxed">
{pageContent.features_section_subtitle}
</p>
)}
</div>
)}
<div className="relative grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
{validFeatures.length > 0 ? (
validFeatures.map((feature: any, index: number) => (
@@ -638,7 +787,7 @@ const HomePage: React.FC = () => {
})()}
{}
{(pageContent?.luxury_section_title || (pageContent?.luxury_features && pageContent.luxury_features.length > 0)) && (
{(pageContent?.sections_enabled?.luxury !== false) && (pageContent?.luxury_section_title || (pageContent?.luxury_features && pageContent.luxury_features.length > 0)) && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
@@ -693,7 +842,7 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.luxury_gallery && Array.isArray(pageContent.luxury_gallery) && pageContent.luxury_gallery.length > 0 && (
{(pageContent?.sections_enabled?.gallery !== false) && pageContent?.luxury_gallery && Array.isArray(pageContent.luxury_gallery) && pageContent.luxury_gallery.length > 0 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
@@ -761,7 +910,7 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.luxury_testimonials && pageContent.luxury_testimonials.length > 0 && (
{(pageContent?.sections_enabled?.testimonials !== false) && pageContent?.luxury_testimonials && pageContent.luxury_testimonials.length > 0 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
@@ -775,9 +924,6 @@ const HomePage: React.FC = () => {
{pageContent.luxury_testimonials_section_subtitle}
</p>
)}
<p className="text-sm sm:text-base md:text-lg text-gray-600 font-light tracking-wide max-w-2xl mx-auto mt-3 md:mt-4 px-4 leading-relaxed">
Hear from our valued guests about their luxury stay
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 md:gap-6 px-4">
{pageContent.luxury_testimonials.map((testimonial, index) => (
@@ -812,13 +958,31 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.stats && Array.isArray(pageContent.stats) && pageContent.stats.length > 0 && (
{(pageContent?.sections_enabled?.stats !== false) && pageContent?.stats && Array.isArray(pageContent.stats) && pageContent.stats.length > 0 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div className="relative bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 rounded-xl md:rounded-2xl p-6 md:p-8 lg:p-10 shadow-xl shadow-black/30 animate-fade-in overflow-hidden border border-[#d4af37]/15">
{}
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-[#d4af37] via-[#f5d76e] to-[#d4af37]"></div>
<div className="absolute inset-0 opacity-8 bg-[radial-gradient(circle_at_1px_1px,#d4af37_1px,transparent_0)] bg-[length:40px_40px]"></div>
{(pageContent?.stats_section_title || pageContent?.stats_section_subtitle) && (
<div className="text-center mb-8 md:mb-10 animate-fade-in relative z-10">
<div className="inline-block mb-3">
<div className="h-0.5 w-16 md:w-20 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
</div>
{pageContent.stats_section_title && (
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-white tracking-tight mb-3 md:mb-4 px-4">
{pageContent.stats_section_title}
</h2>
)}
{pageContent.stats_section_subtitle && (
<p className="text-sm sm:text-base md:text-lg text-gray-300 font-light tracking-wide max-w-2xl mx-auto mt-3 md:mt-4 px-4 leading-relaxed">
{pageContent.stats_section_subtitle}
</p>
)}
</div>
)}
<div className="relative grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6 lg:gap-8">
{pageContent.stats.map((stat, index) => (
<div key={`stat-${index}-${stat.label || index}`} className="text-center group relative">
@@ -851,7 +1015,7 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.amenities && pageContent.amenities.length > 0 && (
{(pageContent?.sections_enabled?.amenities !== false) && pageContent?.amenities && pageContent.amenities.length > 0 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
@@ -896,56 +1060,9 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.testimonials && pageContent.testimonials.length > 0 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
<div className="h-0.5 w-16 md:w-20 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
</div>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-3 md:mb-4 px-4">
{pageContent.testimonials_section_title || 'Guest Testimonials'}
</h2>
<p className="text-sm sm:text-base md:text-lg text-gray-600 font-light tracking-wide max-w-2xl mx-auto mt-3 md:mt-4 px-4 leading-relaxed">
{pageContent.testimonials_section_subtitle || 'See what our guests say about their experience'}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 md:gap-6 px-4">
{pageContent.testimonials.map((testimonial, index) => (
<div key={index} className="relative bg-white rounded-lg md:rounded-xl p-5 md:p-6 shadow-lg shadow-gray-900/5 hover:shadow-xl hover:shadow-[#d4af37]/8 transition-all duration-300 animate-fade-in border border-gray-100/50 hover:border-[#d4af37]/25 hover:-translate-y-1" style={{ animationDelay: `${index * 0.1}s` }}>
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] opacity-0 hover:opacity-100 transition-opacity duration-300 rounded-t-lg md:rounded-t-xl"></div>
<div className="flex items-center mb-4 md:mb-5">
{testimonial.image ? (
<div className="relative">
<img src={testimonial.image} alt={testimonial.name} className="w-12 h-12 md:w-14 md:h-14 rounded-full object-cover mr-3 md:mr-4 border-2 border-[#d4af37]/20 shadow-md" />
<div className="absolute inset-0 rounded-full border border-[#d4af37]/40"></div>
</div>
) : (
<div className="w-12 h-12 md:w-14 md:h-14 rounded-full bg-gradient-to-br from-[#d4af37] to-[#f5d76e] flex items-center justify-center text-white font-bold text-base md:text-lg mr-3 md:mr-4 shadow-md border-2 border-white">
{testimonial.name.charAt(0).toUpperCase()}
</div>
)}
<div>
<h4 className="font-semibold text-gray-900 text-base md:text-lg">{testimonial.name}</h4>
<p className="text-xs md:text-sm text-gray-500 font-light">{testimonial.role}</p>
</div>
</div>
<div className="flex mb-3 md:mb-4 justify-center md:justify-start">
{[...Array(5)].map((_, i) => (
<span key={i} className={`text-base md:text-lg ${i < testimonial.rating ? 'text-[#d4af37]' : 'text-gray-300'}`}>★</span>
))}
</div>
<div className="relative">
<div className="absolute -top-1 -left-1 text-4xl md:text-5xl text-[#d4af37]/8 font-serif">"</div>
<p className="text-sm md:text-base text-gray-700 leading-relaxed italic relative z-10 font-light">"{testimonial.comment}"</p>
</div>
</div>
))}
</div>
</section>
)}
{}
{(pageContent?.about_preview_title || pageContent?.about_preview_content) && (
{(pageContent?.sections_enabled?.about_preview !== false) && (pageContent?.about_preview_title || pageContent?.about_preview_content) && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div className="relative bg-white rounded-xl md:rounded-2xl shadow-xl shadow-[#d4af37]/5 overflow-hidden animate-fade-in border border-gray-100/50">
{}
@@ -995,7 +1112,7 @@ const HomePage: React.FC = () => {
)}
{}
{services.length > 0 && (
{(pageContent?.sections_enabled?.services !== false) && services.length > 0 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
@@ -1011,7 +1128,7 @@ const HomePage: React.FC = () => {
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 md:gap-6 px-4">
{services.slice(0, 6).map((service, index: number) => {
{services.slice(0, pageContent?.services_section_limit || 6).map((service, index: number) => {
const serviceSlug = service.slug || service.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
return (
<Link
@@ -1047,10 +1164,10 @@ const HomePage: React.FC = () => {
</div>
<div className="text-center mt-8 md:mt-10">
<Link
to="/services"
to={pageContent?.services_section_button_link || '/services'}
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg font-medium hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 shadow-lg shadow-[#d4af37]/30 hover:shadow-xl hover:shadow-[#d4af37]/40 hover:-translate-y-0.5"
>
<span>View All Services</span>
<span>{pageContent?.services_section_button_text || 'View All Services'}</span>
<ArrowRight className="w-5 h-5" />
</Link>
</div>
@@ -1058,7 +1175,7 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.luxury_experiences && pageContent.luxury_experiences.length > 0 && (
{(pageContent?.sections_enabled?.experiences !== false) && pageContent?.luxury_experiences && pageContent.luxury_experiences.length > 0 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
@@ -1105,7 +1222,7 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.awards && pageContent.awards.length > 0 && (
{(pageContent?.sections_enabled?.awards !== false) && pageContent?.awards && pageContent.awards.length > 0 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
@@ -1194,7 +1311,7 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.partners && pageContent.partners.length > 0 && (
{(pageContent?.sections_enabled?.partners !== false) && pageContent?.partners && pageContent.partners.length > 0 && (
<section className="w-full py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
@@ -1220,6 +1337,200 @@ const HomePage: React.FC = () => {
</section>
)}
{/* Trust Badges Section */}
{(pageContent?.sections_enabled?.trust_badges !== false) && pageContent?.trust_badges_enabled && pageContent?.trust_badges && Array.isArray(pageContent.trust_badges) && pageContent.trust_badges.length > 0 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
<div className="h-0.5 w-16 md:w-20 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
</div>
{pageContent.trust_badges_section_title && (
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-3 md:mb-4 px-4">
{pageContent.trust_badges_section_title}
</h2>
)}
{pageContent.trust_badges_section_subtitle && (
<p className="text-sm sm:text-base md:text-lg text-gray-600 font-light tracking-wide max-w-2xl mx-auto mt-3 md:mt-4 px-4 leading-relaxed">
{pageContent.trust_badges_section_subtitle}
</p>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6 md:gap-8 px-4">
{pageContent.trust_badges.map((badge, index) => (
<div key={index} className="flex flex-col items-center text-center group">
{badge.link ? (
<a href={badge.link} target="_blank" rel="noopener noreferrer" className="w-full">
<div className="w-24 h-24 md:w-32 md:h-32 mx-auto mb-4 rounded-lg overflow-hidden bg-white p-4 shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-200 hover:border-[#d4af37]/50 group-hover:scale-105">
{badge.logo && (
<img src={badge.logo} alt={badge.name} className="w-full h-full object-contain" />
)}
</div>
{badge.name && (
<h4 className="text-sm md:text-base font-semibold text-gray-900 mt-2">{badge.name}</h4>
)}
{badge.description && (
<p className="text-xs md:text-sm text-gray-600 mt-1">{badge.description}</p>
)}
</a>
) : (
<>
<div className="w-24 h-24 md:w-32 md:h-32 mx-auto mb-4 rounded-lg overflow-hidden bg-white p-4 shadow-lg border border-gray-200">
{badge.logo && (
<img src={badge.logo} alt={badge.name} className="w-full h-full object-contain" />
)}
</div>
{badge.name && (
<h4 className="text-sm md:text-base font-semibold text-gray-900 mt-2">{badge.name}</h4>
)}
{badge.description && (
<p className="text-xs md:text-sm text-gray-600 mt-1">{badge.description}</p>
)}
</>
)}
</div>
))}
</div>
</section>
)}
{/* Promotions Section */}
{(pageContent?.sections_enabled?.promotions !== false) && pageContent?.promotions_enabled && pageContent?.promotions && Array.isArray(pageContent.promotions) && pageContent.promotions.length > 0 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
<div className="h-0.5 w-16 md:w-20 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
</div>
{pageContent.promotions_section_title && (
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-3 md:mb-4 px-4">
{pageContent.promotions_section_title}
</h2>
)}
{pageContent.promotions_section_subtitle && (
<p className="text-sm sm:text-base md:text-lg text-gray-600 font-light tracking-wide max-w-2xl mx-auto mt-3 md:mt-4 px-4 leading-relaxed">
{pageContent.promotions_section_subtitle}
</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 px-4">
{pageContent.promotions.map((promo, index) => (
<div key={index} className="relative bg-white rounded-xl shadow-xl overflow-hidden border border-gray-200 hover:shadow-2xl transition-all duration-300 group">
{promo.image && (
<div className="relative h-48 overflow-hidden">
<img src={promo.image} alt={promo.title} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
{promo.discount && (
<div className="absolute top-4 right-4 bg-red-600 text-white px-4 py-2 rounded-full font-bold text-lg shadow-lg">
{promo.discount}
</div>
)}
</div>
)}
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900 mb-2">{promo.title}</h3>
{promo.description && (
<p className="text-gray-600 mb-4">{promo.description}</p>
)}
{promo.valid_until && (
<p className="text-sm text-gray-500 mb-4">Valid until: {new Date(promo.valid_until).toLocaleDateString()}</p>
)}
{promo.link && (
<Link
to={promo.link}
className="inline-flex items-center gap-2 px-6 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg font-semibold hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300"
>
{promo.button_text || 'Learn More'}
<ArrowRight className="w-4 h-4" />
</Link>
)}
</div>
</div>
))}
</div>
</section>
)}
{/* Blog Section */}
{(pageContent?.sections_enabled?.blog !== false) && pageContent?.blog_enabled && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
<div className="h-0.5 w-16 md:w-20 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
</div>
{pageContent.blog_section_title && (
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-3 md:mb-4 px-4">
{pageContent.blog_section_title}
</h2>
)}
{pageContent.blog_section_subtitle && (
<p className="text-sm sm:text-base md:text-lg text-gray-600 font-light tracking-wide max-w-2xl mx-auto mt-3 md:mt-4 px-4 leading-relaxed">
{pageContent.blog_section_subtitle}
</p>
)}
</div>
{isLoadingBlog ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 px-4">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-xl shadow-lg overflow-hidden border border-gray-200 animate-pulse">
<div className="h-48 bg-gray-200"></div>
<div className="p-6">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
</div>
))}
</div>
) : blogPosts.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 px-4">
{blogPosts.map((post) => (
<Link
key={post.id}
to={`/blog/${post.slug}`}
className="group bg-white rounded-xl shadow-lg overflow-hidden border border-gray-200 hover:shadow-2xl transition-all duration-300 hover:-translate-y-1"
>
{post.featured_image && (
<div className="relative h-48 overflow-hidden">
<img
src={post.featured_image}
alt={post.title}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
/>
</div>
)}
<div className="p-6">
{post.published_at && (
<p className="text-xs text-gray-500 mb-2">
{new Date(post.published_at).toLocaleDateString()}
</p>
)}
<h3 className="text-xl font-bold text-gray-900 mb-2 group-hover:text-[#d4af37] transition-colors">
{post.title}
</h3>
{post.excerpt && (
<p className="text-gray-600 mb-4 line-clamp-3">{post.excerpt}</p>
)}
<div className="flex items-center gap-2 text-[#d4af37] font-semibold group-hover:gap-3 transition-all">
Read More
<ArrowRight className="w-4 h-4" />
</div>
</div>
</Link>
))}
</div>
) : null}
{blogPosts.length > 0 && (
<div className="text-center mt-8 md:mt-10">
<Link
to="/blog"
className="inline-flex items-center gap-2 px-8 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg font-semibold hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 shadow-lg shadow-[#d4af37]/30 hover:shadow-xl hover:shadow-[#d4af37]/40 hover:-translate-y-0.5"
>
View All Posts
<ArrowRight className="w-5 h-5" />
</Link>
</div>
)}
</section>
)}
</div>
</div>

View File

@@ -38,9 +38,13 @@ export interface PageContent {
hero_title?: string;
hero_subtitle?: string;
hero_image?: string;
hero_video_url?: string;
hero_video_poster?: string;
story_content?: string;
values?: Array<{ icon?: string; title: string; description: string }>;
features?: Array<{ icon?: string; title: string; description: string; image?: string }>;
features_section_title?: string;
features_section_subtitle?: string;
about_hero_image?: string;
mission?: string;
vision?: string;
@@ -72,8 +76,18 @@ export interface PageContent {
about_preview_content?: string;
about_preview_image?: string;
stats?: Array<{ number: string; label: string; icon?: string }>;
stats_section_title?: string;
stats_section_subtitle?: string;
rooms_section_title?: string;
rooms_section_subtitle?: string;
rooms_section_button_text?: string;
rooms_section_button_link?: string;
rooms_section_enabled?: boolean;
luxury_services_section_title?: string;
luxury_services_section_subtitle?: string;
services_section_button_text?: string;
services_section_button_link?: string;
services_section_limit?: number;
luxury_services?: Array<{
icon?: string;
title: string;
@@ -115,6 +129,50 @@ export interface PageContent {
partners_section_title?: string;
partners_section_subtitle?: string;
partners?: Array<{ name: string; logo: string; link?: string }>;
sections_enabled?: {
features?: boolean;
luxury?: boolean;
gallery?: boolean;
testimonials?: boolean;
stats?: boolean;
amenities?: boolean;
about_preview?: boolean;
services?: boolean;
experiences?: boolean;
awards?: boolean;
cta?: boolean;
partners?: boolean;
rooms?: boolean;
newsletter?: boolean;
trust_badges?: boolean;
promotions?: boolean;
blog?: boolean;
};
newsletter_section_title?: string;
newsletter_section_subtitle?: string;
newsletter_placeholder?: string;
newsletter_button_text?: string;
newsletter_enabled?: boolean;
trust_badges_section_title?: string;
trust_badges_section_subtitle?: string;
trust_badges?: Array<{ name: string; logo: string; description?: string; link?: string }>;
trust_badges_enabled?: boolean;
promotions_section_title?: string;
promotions_section_subtitle?: string;
promotions?: Array<{
title: string;
description: string;
image?: string;
discount?: string;
valid_until?: string;
link?: string;
button_text?: string;
}>;
promotions_enabled?: boolean;
blog_section_title?: string;
blog_section_subtitle?: string;
blog_posts_limit?: number;
blog_enabled?: boolean;
is_active?: boolean;
created_at?: string;
updated_at?: string;
@@ -163,9 +221,13 @@ export interface UpdatePageContentData {
hero_title?: string;
hero_subtitle?: string;
hero_image?: string;
hero_video_url?: string;
hero_video_poster?: string;
story_content?: string;
values?: Array<{ icon?: string; title: string; description: string }>;
features?: Array<{ icon?: string; title: string; description: string; image?: string }>;
features_section_title?: string;
features_section_subtitle?: string;
about_hero_image?: string;
mission?: string;
vision?: string;
@@ -197,8 +259,18 @@ export interface UpdatePageContentData {
about_preview_content?: string;
about_preview_image?: string;
stats?: Array<{ number: string; label: string; icon?: string }>;
stats_section_title?: string;
stats_section_subtitle?: string;
rooms_section_title?: string;
rooms_section_subtitle?: string;
rooms_section_button_text?: string;
rooms_section_button_link?: string;
rooms_section_enabled?: boolean;
luxury_services_section_title?: string;
luxury_services_section_subtitle?: string;
services_section_button_text?: string;
services_section_button_link?: string;
services_section_limit?: number;
luxury_services?: Array<{
icon?: string;
title: string;
@@ -240,6 +312,50 @@ export interface UpdatePageContentData {
partners_section_title?: string;
partners_section_subtitle?: string;
partners?: Array<{ name: string; logo: string; link?: string }>;
sections_enabled?: {
features?: boolean;
luxury?: boolean;
gallery?: boolean;
testimonials?: boolean;
stats?: boolean;
amenities?: boolean;
about_preview?: boolean;
services?: boolean;
experiences?: boolean;
awards?: boolean;
cta?: boolean;
partners?: boolean;
rooms?: boolean;
newsletter?: boolean;
trust_badges?: boolean;
promotions?: boolean;
blog?: boolean;
};
newsletter_section_title?: string;
newsletter_section_subtitle?: string;
newsletter_placeholder?: string;
newsletter_button_text?: string;
newsletter_enabled?: boolean;
trust_badges_section_title?: string;
trust_badges_section_subtitle?: string;
trust_badges?: Array<{ name: string; logo: string; description?: string; link?: string }>;
trust_badges_enabled?: boolean;
promotions_section_title?: string;
promotions_section_subtitle?: string;
promotions?: Array<{
title: string;
description: string;
image?: string;
discount?: string;
valid_until?: string;
link?: string;
button_text?: string;
}>;
promotions_enabled?: boolean;
blog_section_title?: string;
blog_section_subtitle?: string;
blog_posts_limit?: number;
blog_enabled?: boolean;
is_active?: boolean;
}
@@ -404,6 +520,15 @@ const pageContentService = {
if (data.achievements) {
updateData.achievements = data.achievements;
}
if (data.trust_badges) {
updateData.trust_badges = data.trust_badges;
}
if (data.promotions) {
updateData.promotions = data.promotions;
}
if (data.sections_enabled) {
updateData.sections_enabled = data.sections_enabled;
}
const response = await apiClient.put<PageContentResponse>(
`/page-content/${pageType}`,

View File

@@ -97,6 +97,7 @@ class EmailCampaignService {
reply_to_email?: string;
track_opens?: boolean;
track_clicks?: boolean;
recipient_type?: 'users' | 'subscribers' | 'both';
}): Promise<{ campaign_id: number }> {
const response = await apiClient.post('/email-campaigns', data);
return response.data;
@@ -165,6 +166,26 @@ class EmailCampaignService {
return response.data;
}
// Newsletter Subscribers
async getNewsletterSubscribers(params?: {
page?: number;
limit?: number;
}): Promise<{
subscribers: Array<{
email: string;
user_id: number | null;
name: string | null;
type: string;
}>;
total: number;
page: number;
limit: number;
total_pages: number;
}> {
const response = await apiClient.get('/email-campaigns/newsletter/subscribers', { params });
return response.data.data;
}
async createDripSequence(data: {
name: string;
description?: string;

View File

@@ -6,7 +6,9 @@ import {
FileText,
X,
Layers,
Target
Target,
Users,
Search as SearchIcon
} from 'lucide-react';
import { emailCampaignService, Campaign, CampaignSegment, EmailTemplate, DripSequence, CampaignAnalytics } from '../../features/notifications/services/emailCampaignService';
import { toast } from 'react-toastify';
@@ -54,7 +56,8 @@ const EmailCampaignManagementPage: React.FC = () => {
from_name: '',
from_email: '',
track_opens: true,
track_clicks: true
track_clicks: true,
recipient_type: 'users' as 'users' | 'subscribers' | 'both'
});
const [segmentForm, setSegmentForm] = useState({
@@ -557,30 +560,53 @@ const SegmentsTab: React.FC<{
segments: CampaignSegment[];
onRefresh: () => void;
onCreate: () => void;
}> = ({ segments, onCreate }) => (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-xl font-semibold">Segments</h3>
<button
onClick={onCreate}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Create Segment
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{segments.map((segment) => (
<div key={segment.id} className="border rounded-xl p-4">
<h4 className="font-semibold">{segment.name}</h4>
<p className="text-sm text-gray-600 mt-1">{segment.description}</p>
<p className="text-sm text-blue-600 mt-2">
Estimated: {segment.estimated_count || 0} users
</p>
}> = ({ segments, onCreate }) => {
const [showSubscribersModal, setShowSubscribersModal] = React.useState(false);
const newsletterSegment = segments.find(s => s.name === "Newsletter Subscribers");
return (
<>
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-xl font-semibold">Segments</h3>
<button
onClick={onCreate}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Create Segment
</button>
</div>
))}
</div>
</div>
);
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{segments.map((segment) => (
<div key={segment.id} className="border rounded-xl p-4">
<h4 className="font-semibold">{segment.name}</h4>
<p className="text-sm text-gray-600 mt-1">{segment.description}</p>
<div className="flex items-center justify-between mt-2">
<p className="text-sm text-blue-600">
Estimated: {segment.estimated_count || 0} users
</p>
{segment.name === "Newsletter Subscribers" && (
<button
onClick={() => setShowSubscribersModal(true)}
className="px-3 py-1 text-sm bg-green-500 text-white rounded-lg hover:bg-green-600"
>
View Subscribers
</button>
)}
</div>
</div>
))}
</div>
</div>
{showSubscribersModal && newsletterSegment && (
<SubscribersModal
segment={newsletterSegment}
onClose={() => setShowSubscribersModal(false)}
/>
)}
</>
);
};
const TemplatesTab: React.FC<{
templates: EmailTemplate[];
@@ -726,6 +752,15 @@ const CampaignModal: React.FC<{
<option value="abandoned_booking">Abandoned Booking</option>
<option value="welcome">Welcome</option>
</select>
<select
value={form.recipient_type || 'users'}
onChange={(e) => setForm({ ...form, recipient_type: e.target.value as 'users' | 'subscribers' | 'both' })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
>
<option value="users">All Platform Users</option>
<option value="subscribers">Newsletter Subscribers Only</option>
<option value="both">Both (Users + Subscribers)</option>
</select>
<select
value={form.segment_id || ''}
onChange={(e) => setForm({ ...form, segment_id: e.target.value ? parseInt(e.target.value) : undefined })}
@@ -957,5 +992,140 @@ const DripSequenceModal: React.FC<{
</div>
);
const SubscribersModal: React.FC<{
segment: CampaignSegment;
onClose: () => void;
}> = ({ segment, onClose }) => {
const [subscribers, setSubscribers] = useState<Array<{
email: string;
user_id: number | null;
name: string | null;
type: string;
}>>([]);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState('');
const limit = 20;
useEffect(() => {
fetchSubscribers();
}, [currentPage]);
const fetchSubscribers = async () => {
setLoading(true);
try {
const data = await emailCampaignService.getNewsletterSubscribers({
page: currentPage,
limit: limit
});
setSubscribers(data.subscribers);
setTotalPages(data.total_pages);
setTotal(data.total);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to fetch subscribers');
} finally {
setLoading(false);
}
};
const filteredSubscribers = subscribers.filter(sub =>
sub.email.toLowerCase().includes(search.toLowerCase()) ||
(sub.name && sub.name.toLowerCase().includes(search.toLowerCase()))
);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col">
<div className="p-6 border-b flex items-center justify-between">
<div>
<h3 className="text-2xl font-bold text-gray-900">{segment.name}</h3>
<p className="text-sm text-gray-600 mt-1">{segment.description}</p>
<p className="text-sm text-blue-600 mt-1">Total: {total} subscribers</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6 flex-1 overflow-y-auto">
<div className="mb-4">
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
{loading ? (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
) : filteredSubscribers.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No subscribers found
</div>
) : (
<div className="space-y-2">
{filteredSubscribers.map((subscriber, index) => (
<div
key={index}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50"
>
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<Users className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="font-medium text-gray-900">{subscriber.email}</p>
{subscriber.name && (
<p className="text-sm text-gray-500">{subscriber.name}</p>
)}
</div>
</div>
<span className="px-3 py-1 text-xs bg-green-100 text-green-800 rounded-full">
{subscriber.type}
</span>
</div>
))}
</div>
)}
</div>
{totalPages > 1 && (
<div className="p-6 border-t flex items-center justify-between">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-4 py-2 border rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-4 py-2 border rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Next
</button>
</div>
)}
</div>
</div>
);
};
export default EmailCampaignManagementPage;

View File

@@ -0,0 +1,651 @@
import React, { useEffect, useState } from 'react';
import { Search, Plus, Edit, Eye, CheckCircle, Clock, X, Filter, FileCheck, AlertCircle } from 'lucide-react';
import advancedRoomService, { RoomInspection } from '../../features/rooms/services/advancedRoomService';
import roomService, { Room } from '../../features/rooms/services/roomService';
import userService, { User } from '../../features/auth/services/userService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { formatDate } from '../../shared/utils/format';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
const InspectionManagementPage: React.FC = () => {
const [inspections, setInspections] = useState<RoomInspection[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingInspection, setEditingInspection] = useState<RoomInspection | null>(null);
const [viewingInspection, setViewingInspection] = useState<RoomInspection | null>(null);
const [rooms, setRooms] = useState<Room[]>([]);
const [staffMembers, setStaffMembers] = useState<User[]>([]);
const [filters, setFilters] = useState({
search: '',
status: '',
inspection_type: '',
room_id: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 20;
const [formData, setFormData] = useState({
room_id: 0,
booking_id: 0,
inspection_type: 'routine',
scheduled_at: new Date(),
inspected_by: 0,
checklist_items: [] as any[],
});
const inspectionTypes = [
{ value: 'pre_checkin', label: 'Pre Check-in', color: 'bg-blue-100 text-blue-800' },
{ value: 'post_checkout', label: 'Post Check-out', color: 'bg-green-100 text-green-800' },
{ value: 'routine', label: 'Routine', color: 'bg-purple-100 text-purple-800' },
{ value: 'maintenance', label: 'Maintenance', color: 'bg-orange-100 text-orange-800' },
{ value: 'damage', label: 'Damage', color: 'bg-red-100 text-red-800' },
];
const statuses = [
{ value: 'pending', label: 'Pending', color: 'bg-yellow-100 text-yellow-800' },
{ value: 'in_progress', label: 'In Progress', color: 'bg-blue-100 text-blue-800' },
{ value: 'completed', label: 'Completed', color: 'bg-green-100 text-green-800' },
{ value: 'failed', label: 'Failed', color: 'bg-red-100 text-red-800' },
{ value: 'cancelled', label: 'Cancelled', color: 'bg-gray-100 text-gray-800' },
];
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchInspections();
fetchRooms();
fetchStaffMembers();
}, [filters, currentPage]);
const fetchInspections = async () => {
try {
setLoading(true);
const params: any = {
page: currentPage,
limit: itemsPerPage,
};
if (filters.status) params.status = filters.status;
if (filters.inspection_type) params.inspection_type = filters.inspection_type;
if (filters.room_id) params.room_id = parseInt(filters.room_id);
const response = await advancedRoomService.getRoomInspections(params);
if (response.status === 'success' && response.data) {
let inspectionList = response.data.inspections || [];
if (filters.search) {
inspectionList = inspectionList.filter((inspection: RoomInspection) =>
inspection.room_number?.toLowerCase().includes(filters.search.toLowerCase()) ||
inspection.inspector_name?.toLowerCase().includes(filters.search.toLowerCase())
);
}
setInspections(inspectionList);
setTotalPages(response.data.pagination?.total_pages || 1);
setTotalItems(response.data.pagination?.total || 0);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load inspections');
} finally {
setLoading(false);
}
};
const fetchRooms = async () => {
try {
const response = await roomService.getRooms({ limit: 1000 });
if (response.data && response.data.rooms) {
setRooms(response.data.rooms);
}
} catch (error) {
console.error('Error fetching rooms:', error);
}
};
const fetchStaffMembers = async () => {
try {
const response = await userService.getUsers({ role: 'staff', limit: 100 });
if (response.data && response.data.users) {
setStaffMembers(response.data.users);
}
} catch (error) {
console.error('Error fetching staff members:', error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.room_id && !editingInspection) {
toast.error('Please select a room');
return;
}
try {
if (editingInspection) {
// Update existing inspection
const updateData: any = {
status: editingInspection.status,
};
if (formData.inspected_by && formData.inspected_by !== editingInspection.inspected_by) {
// Reassignment would need a different endpoint
}
await advancedRoomService.updateRoomInspection(editingInspection.id, updateData);
toast.success('Inspection updated successfully');
} else {
// Create new inspection
const dataToSubmit: any = {
room_id: formData.room_id,
inspection_type: formData.inspection_type,
scheduled_at: formData.scheduled_at.toISOString(),
checklist_items: formData.checklist_items,
};
if (formData.booking_id) {
dataToSubmit.booking_id = formData.booking_id;
}
if (formData.inspected_by) {
dataToSubmit.inspected_by = formData.inspected_by;
}
await advancedRoomService.createRoomInspection(dataToSubmit);
toast.success('Inspection created successfully');
}
setShowModal(false);
setEditingInspection(null);
resetForm();
fetchInspections();
} catch (error: any) {
toast.error(error.response?.data?.message || `Unable to ${editingInspection ? 'update' : 'create'} inspection`);
}
};
const handleStatusUpdate = async (inspectionId: number, newStatus: string) => {
try {
await advancedRoomService.updateRoomInspection(inspectionId, {
status: newStatus,
});
toast.success('Inspection status updated successfully');
fetchInspections();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to update status');
}
};
const resetForm = () => {
setFormData({
room_id: 0,
booking_id: 0,
inspection_type: 'routine',
scheduled_at: new Date(),
inspected_by: 0,
checklist_items: [],
});
};
const getStatusBadge = (status: string) => {
const statusObj = statuses.find((s) => s.value === status);
return statusObj || statuses[0];
};
const getTypeBadge = (type: string) => {
const typeObj = inspectionTypes.find((t) => t.value === type);
return typeObj || inspectionTypes[2];
};
const getPendingInspections = () => inspections.filter((i) => i.status === 'pending').length;
const getCompletedInspections = () => inspections.filter((i) => i.status === 'completed').length;
const getFailedInspections = () => inspections.filter((i) => i.status === 'failed').length;
if (loading && inspections.length === 0) {
return <Loading fullScreen text="Loading inspections..." />;
}
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Inspection Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage room inspections and quality control</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="w-5 h-5" />
New Inspection
</button>
</div>
{/* Filters */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search inspections..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Statuses</option>
{statuses.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
<select
value={filters.inspection_type}
onChange={(e) => setFilters({ ...filters, inspection_type: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Types</option>
{inspectionTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<select
value={filters.room_id}
onChange={(e) => setFilters({ ...filters, room_id: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Rooms</option>
{rooms.map((room) => (
<option key={room.id} value={room.id}>
Room {room.room_number}
</option>
))}
</select>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Total Inspections</p>
<p className="text-3xl font-bold text-slate-900 mt-2">{totalItems}</p>
</div>
<FileCheck className="w-12 h-12 text-amber-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Pending</p>
<p className="text-3xl font-bold text-yellow-600 mt-2">{getPendingInspections()}</p>
</div>
<Clock className="w-12 h-12 text-yellow-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Completed</p>
<p className="text-3xl font-bold text-green-600 mt-2">{getCompletedInspections()}</p>
</div>
<CheckCircle className="w-12 h-12 text-green-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Failed</p>
<p className="text-3xl font-bold text-red-600 mt-2">{getFailedInspections()}</p>
</div>
<AlertCircle className="w-12 h-12 text-red-500" />
</div>
</div>
</div>
{/* Inspections Table */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b-2 border-slate-200">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Inspection
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Room
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Scheduled
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Inspector
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Score
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{inspections.map((inspection) => {
const statusBadge = getStatusBadge(inspection.status);
const typeBadge = getTypeBadge(inspection.inspection_type);
return (
<tr key={inspection.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div className="font-semibold text-slate-900">
{inspectionTypes.find((t) => t.value === inspection.inspection_type)?.label || 'Inspection'}
</div>
{inspection.overall_notes && (
<div className="text-sm text-slate-500 mt-1 line-clamp-2">{inspection.overall_notes}</div>
)}
</td>
<td className="px-6 py-4">
<div className="font-semibold text-slate-900">
Room {inspection.room_number || inspection.room_id}
</div>
</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${typeBadge.color}`}>
{typeBadge.label}
</span>
</td>
<td className="px-6 py-4">
<div className="text-slate-900 font-medium">
{formatDate(inspection.scheduled_at)}
</div>
{inspection.completed_at && (
<div className="text-xs text-slate-500 mt-1">
Completed: {formatDate(inspection.completed_at)}
</div>
)}
</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusBadge.color}`}>
{statusBadge.label}
</span>
</td>
<td className="px-6 py-4 text-slate-600">
{inspection.inspector_name || 'Unassigned'}
</td>
<td className="px-6 py-4">
{inspection.overall_score !== null && inspection.overall_score !== undefined ? (
<div className="flex items-center gap-1">
<span className="font-semibold text-slate-900">{inspection.overall_score}</span>
<span className="text-slate-500 text-sm">/ 5</span>
</div>
) : (
<span className="text-slate-400"></span>
)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setViewingInspection(inspection)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="View Details"
>
<Eye className="w-5 h-5" />
</button>
<button
onClick={() => handleEdit(inspection)}
className="p-2 text-amber-600 hover:bg-amber-50 rounded-lg transition-colors"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
{inspection.status !== 'completed' && inspection.status !== 'cancelled' && (
<select
value={inspection.status}
onChange={(e) => handleStatusUpdate(inspection.id, e.target.value)}
className="px-3 py-1.5 text-sm border-2 border-slate-200 rounded-lg focus:border-amber-400 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
{statuses.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{inspections.length === 0 && !loading && (
<div className="text-center py-12">
<FileCheck className="w-16 h-16 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 text-lg">No inspections found</p>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">
{editingInspection ? 'Edit Inspection' : 'Create Inspection'}
</h2>
<button
onClick={() => {
setShowModal(false);
setEditingInspection(null);
resetForm();
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Room *</label>
<select
value={formData.room_id}
onChange={(e) => setFormData({ ...formData, room_id: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
>
<option value={0}>Select Room</option>
{rooms.map((room) => (
<option key={room.id} value={room.id}>
Room {room.room_number}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Inspection Type *</label>
<select
value={formData.inspection_type}
onChange={(e) => setFormData({ ...formData, inspection_type: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
>
{inspectionTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Scheduled At *</label>
<DatePicker
selected={formData.scheduled_at}
onChange={(date: Date) => setFormData({ ...formData, scheduled_at: date })}
showTimeSelect
dateFormat="MMMM d, yyyy h:mm aa"
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Inspector</label>
<select
value={formData.inspected_by}
onChange={(e) => setFormData({ ...formData, inspected_by: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
>
<option value={0}>Unassigned</option>
{staffMembers.map((staff) => (
<option key={staff.id} value={staff.id}>
{staff.full_name}
</option>
))}
</select>
</div>
</div>
<div className="flex gap-4 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowModal(false);
setEditingInspection(null);
resetForm();
}}
className="flex-1 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700"
>
{editingInspection ? 'Update Inspection' : 'Create Inspection'}
</button>
</div>
</form>
</div>
</div>
)}
{/* View Details Modal - Simplified */}
{viewingInspection && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">Inspection Details</h2>
<button
onClick={() => setViewingInspection(null)}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-slate-600">Room</p>
<p className="font-semibold">Room {viewingInspection.room_number || viewingInspection.room_id}</p>
</div>
<div>
<p className="text-sm text-slate-600">Type</p>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getTypeBadge(viewingInspection.inspection_type).color}`}>
{getTypeBadge(viewingInspection.inspection_type).label}
</span>
</div>
<div>
<p className="text-sm text-slate-600">Status</p>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusBadge(viewingInspection.status).color}`}>
{getStatusBadge(viewingInspection.status).label}
</span>
</div>
<div>
<p className="text-sm text-slate-600">Inspector</p>
<p className="font-semibold">{viewingInspection.inspector_name || 'Unassigned'}</p>
</div>
</div>
{viewingInspection.overall_score !== null && (
<div>
<p className="text-sm text-slate-600">Overall Score</p>
<p className="text-2xl font-bold">{viewingInspection.overall_score} / 5</p>
</div>
)}
{viewingInspection.overall_notes && (
<div>
<p className="text-sm text-slate-600 mb-2">Notes</p>
<p className="text-slate-900">{viewingInspection.overall_notes}</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
const handleEdit = (inspection: RoomInspection) => {
setEditingInspection(inspection);
setFormData({
room_id: inspection.room_id,
booking_id: inspection.booking_id || 0,
inspection_type: inspection.inspection_type,
scheduled_at: new Date(inspection.scheduled_at),
inspected_by: inspection.inspected_by || 0,
checklist_items: inspection.checklist_items || [],
});
setShowModal(true);
};
};
export default InspectionManagementPage;

View File

@@ -0,0 +1,808 @@
import React, { useEffect, useState } from 'react';
import { Search, Plus, Edit, Trash2, Package, AlertTriangle, TrendingDown, Filter, X } from 'lucide-react';
import inventoryService, { InventoryItem, ReorderRequest } from '../../features/inventory/services/inventoryService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { formatDate } from '../../shared/utils/format';
const InventoryManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [items, setItems] = useState<InventoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
const [showReorderModal, setShowReorderModal] = useState(false);
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
const [reorderRequests, setReorderRequests] = useState<ReorderRequest[]>([]);
const [lowStockItems, setLowStockItems] = useState<InventoryItem[]>([]);
const [filters, setFilters] = useState({
search: '',
category: '',
low_stock: false,
is_active: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 20;
const [formData, setFormData] = useState({
name: '',
description: '',
category: 'cleaning_supplies',
unit: 'piece',
minimum_quantity: 0,
maximum_quantity: 0,
reorder_quantity: 0,
unit_cost: 0,
supplier: '',
supplier_contact: '',
storage_location: '',
barcode: '',
sku: '',
notes: '',
is_active: true,
is_tracked: true,
});
const categories = [
{ value: 'cleaning_supplies', label: 'Cleaning Supplies' },
{ value: 'linens', label: 'Linens' },
{ value: 'toiletries', label: 'Toiletries' },
{ value: 'amenities', label: 'Amenities' },
{ value: 'maintenance', label: 'Maintenance' },
{ value: 'food_beverage', label: 'Food & Beverage' },
{ value: 'other', label: 'Other' },
];
const units = [
{ value: 'piece', label: 'Piece' },
{ value: 'box', label: 'Box' },
{ value: 'bottle', label: 'Bottle' },
{ value: 'roll', label: 'Roll' },
{ value: 'pack', label: 'Pack' },
{ value: 'liter', label: 'Liter' },
{ value: 'kilogram', label: 'Kilogram' },
{ value: 'meter', label: 'Meter' },
{ value: 'other', label: 'Other' },
];
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchItems();
fetchLowStockItems();
fetchReorderRequests();
}, [filters, currentPage]);
const fetchItems = async () => {
try {
setLoading(true);
const params: any = {
page: currentPage,
limit: itemsPerPage,
};
if (filters.category) params.category = filters.category;
if (filters.low_stock) params.low_stock = true;
if (filters.is_active !== '') params.is_active = filters.is_active === 'true';
const response = await inventoryService.getInventoryItems(params);
if (response.status === 'success' && response.data) {
let itemList = response.data.items || [];
if (filters.search) {
itemList = itemList.filter((item: InventoryItem) =>
item.name.toLowerCase().includes(filters.search.toLowerCase()) ||
item.description?.toLowerCase().includes(filters.search.toLowerCase()) ||
item.sku?.toLowerCase().includes(filters.search.toLowerCase()) ||
item.barcode?.toLowerCase().includes(filters.search.toLowerCase())
);
}
setItems(itemList);
setTotalPages(response.data.pagination?.total_pages || 1);
setTotalItems(response.data.pagination?.total || 0);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load inventory items');
} finally {
setLoading(false);
}
};
const fetchLowStockItems = async () => {
try {
const response = await inventoryService.getLowStockItems();
if (response.status === 'success' && response.data) {
setLowStockItems(response.data.items || []);
}
} catch (error) {
// Silently fail - not critical
}
};
const fetchReorderRequests = async () => {
try {
const response = await inventoryService.getReorderRequests({ status: 'pending' });
if (response.status === 'success' && response.data) {
setReorderRequests(response.data.requests || []);
}
} catch (error) {
// Silently fail - not critical
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingItem) {
await inventoryService.updateInventoryItem(editingItem.id, formData);
toast.success('Inventory item updated successfully');
} else {
await inventoryService.createInventoryItem(formData);
toast.success('Inventory item created successfully');
}
setShowModal(false);
resetForm();
fetchItems();
fetchLowStockItems();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to save inventory item');
}
};
const handleEdit = (item: InventoryItem) => {
setEditingItem(item);
setFormData({
name: item.name,
description: item.description || '',
category: item.category,
unit: item.unit,
minimum_quantity: item.minimum_quantity,
maximum_quantity: item.maximum_quantity || 0,
reorder_quantity: item.reorder_quantity || 0,
unit_cost: item.unit_cost || 0,
supplier: item.supplier || '',
supplier_contact: '',
storage_location: item.storage_location || '',
barcode: item.barcode || '',
sku: item.sku || '',
notes: '',
is_active: item.is_active,
is_tracked: item.is_tracked,
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this inventory item?')) {
return;
}
try {
await inventoryService.updateInventoryItem(id, { is_active: false });
toast.success('Inventory item deactivated successfully');
fetchItems();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete inventory item');
}
};
const resetForm = () => {
setEditingItem(null);
setFormData({
name: '',
description: '',
category: 'cleaning_supplies',
unit: 'piece',
minimum_quantity: 0,
maximum_quantity: 0,
reorder_quantity: 0,
unit_cost: 0,
supplier: '',
supplier_contact: '',
storage_location: '',
barcode: '',
sku: '',
notes: '',
is_active: true,
is_tracked: true,
});
};
const openReorderModal = (item: InventoryItem) => {
setSelectedItem(item);
setShowReorderModal(true);
};
const handleCreateReorder = async (quantity: number, priority: string, notes: string) => {
if (!selectedItem) return;
try {
await inventoryService.createReorderRequest({
item_id: selectedItem.id,
requested_quantity: quantity,
priority,
notes,
});
toast.success('Reorder request created successfully');
setShowReorderModal(false);
setSelectedItem(null);
fetchReorderRequests();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to create reorder request');
}
};
const getStockStatus = (item: InventoryItem) => {
if (!item.is_tracked) return { color: 'bg-slate-100 text-slate-700', label: 'Not Tracked' };
if (item.is_low_stock) return { color: 'bg-red-100 text-red-800', label: 'Low Stock' };
if (item.maximum_quantity && item.current_quantity >= item.maximum_quantity) {
return { color: 'bg-blue-100 text-blue-800', label: 'Max Stock' };
}
return { color: 'bg-green-100 text-green-800', label: 'In Stock' };
};
if (loading && items.length === 0) {
return <Loading fullScreen text="Loading inventory..." />;
}
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Inventory Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage hotel inventory and supplies</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="w-5 h-5" />
Add Item
</button>
</div>
{/* Alerts */}
{lowStockItems.length > 0 && (
<div className="bg-gradient-to-r from-red-50 to-orange-50 border-2 border-red-200 rounded-xl p-4 animate-fade-in">
<div className="flex items-center gap-3">
<AlertTriangle className="w-6 h-6 text-red-600" />
<div>
<h3 className="font-semibold text-red-900">Low Stock Alert</h3>
<p className="text-red-700 text-sm">
{lowStockItems.length} item{lowStockItems.length > 1 ? 's' : ''} below minimum quantity
</p>
</div>
</div>
</div>
)}
{/* Filters */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search items..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.category}
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
<select
value={filters.is_active}
onChange={(e) => setFilters({ ...filters, is_active: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
<label className="flex items-center gap-3 px-4 py-3.5 bg-gradient-to-r from-slate-50 to-white border-2 border-slate-200 rounded-xl cursor-pointer hover:border-amber-400 transition-all">
<input
type="checkbox"
checked={filters.low_stock}
onChange={(e) => setFilters({ ...filters, low_stock: e.target.checked })}
className="w-5 h-5 text-amber-600 rounded focus:ring-amber-500"
/>
<span className="text-slate-700 font-medium">Low Stock Only</span>
</label>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Total Items</p>
<p className="text-3xl font-bold text-slate-900 mt-2">{totalItems}</p>
</div>
<Package className="w-12 h-12 text-amber-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Low Stock</p>
<p className="text-3xl font-bold text-red-600 mt-2">{lowStockItems.length}</p>
</div>
<AlertTriangle className="w-12 h-12 text-red-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Pending Orders</p>
<p className="text-3xl font-bold text-orange-600 mt-2">{reorderRequests.length}</p>
</div>
<TrendingDown className="w-12 h-12 text-orange-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Active Items</p>
<p className="text-3xl font-bold text-green-600 mt-2">
{items.filter((i) => i.is_active).length}
</p>
</div>
<Package className="w-12 h-12 text-green-500" />
</div>
</div>
</div>
{/* Items Table */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b-2 border-slate-200">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Item
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Category
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Quantity
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Location
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Cost
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{items.map((item) => {
const stockStatus = getStockStatus(item);
return (
<tr key={item.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div>
<div className="font-semibold text-slate-900">{item.name}</div>
{item.description && (
<div className="text-sm text-slate-500 mt-1">{item.description}</div>
)}
{item.sku && (
<div className="text-xs text-slate-400 mt-1">SKU: {item.sku}</div>
)}
</div>
</td>
<td className="px-6 py-4">
<span className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm font-medium">
{categories.find((c) => c.value === item.category)?.label || item.category}
</span>
</td>
<td className="px-6 py-4">
<div className="text-slate-900 font-semibold">
{item.current_quantity} {units.find((u) => u.value === item.unit)?.label || item.unit}
</div>
{item.is_tracked && (
<div className="text-xs text-slate-500 mt-1">
Min: {item.minimum_quantity}
</div>
)}
</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${stockStatus.color}`}>
{stockStatus.label}
</span>
</td>
<td className="px-6 py-4 text-slate-600">
{item.storage_location || '—'}
</td>
<td className="px-6 py-4 text-slate-900 font-semibold">
{item.unit_cost ? formatCurrency(item.unit_cost) : '—'}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{item.is_low_stock && (
<button
onClick={() => openReorderModal(item)}
className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
title="Reorder"
>
<TrendingDown className="w-5 h-5" />
</button>
)}
<button
onClick={() => handleEdit(item)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(item.id)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Delete"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{items.length === 0 && !loading && (
<div className="text-center py-12">
<Package className="w-16 h-16 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 text-lg">No inventory items found</p>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">
{editingItem ? 'Edit Inventory Item' : 'Add Inventory Item'}
</h2>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Item Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Category *
</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
>
{categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
rows={3}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Unit *
</label>
<select
value={formData.unit}
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
>
{units.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Min Quantity *
</label>
<input
type="number"
value={formData.minimum_quantity}
onChange={(e) => setFormData({ ...formData, minimum_quantity: parseFloat(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
min="0"
step="0.01"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Max Quantity
</label>
<input
type="number"
value={formData.maximum_quantity}
onChange={(e) => setFormData({ ...formData, maximum_quantity: parseFloat(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
min="0"
step="0.01"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Unit Cost
</label>
<input
type="number"
value={formData.unit_cost}
onChange={(e) => setFormData({ ...formData, unit_cost: parseFloat(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
min="0"
step="0.01"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Storage Location
</label>
<input
type="text"
value={formData.storage_location}
onChange={(e) => setFormData({ ...formData, storage_location: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Supplier
</label>
<input
type="text"
value={formData.supplier}
onChange={(e) => setFormData({ ...formData, supplier: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
SKU
</label>
<input
type="text"
value={formData.sku}
onChange={(e) => setFormData({ ...formData, sku: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
/>
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="w-5 h-5 text-amber-600 rounded"
/>
<span className="text-slate-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.is_tracked}
onChange={(e) => setFormData({ ...formData, is_tracked: e.target.checked })}
className="w-5 h-5 text-amber-600 rounded"
/>
<span className="text-slate-700 font-medium">Track Quantity</span>
</label>
</div>
<div className="flex gap-4 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="flex-1 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all"
>
{editingItem ? 'Update Item' : 'Create Item'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Reorder Modal - Simplified version */}
{showReorderModal && selectedItem && (
<ReorderModal
item={selectedItem}
onClose={() => {
setShowReorderModal(false);
setSelectedItem(null);
}}
onSubmit={handleCreateReorder}
/>
)}
</div>
);
};
// Simple Reorder Modal Component
const ReorderModal: React.FC<{
item: InventoryItem;
onClose: () => void;
onSubmit: (quantity: number, priority: string, notes: string) => void;
}> = ({ item, onClose, onSubmit }) => {
const [quantity, setQuantity] = useState(item.reorder_quantity || item.minimum_quantity);
const [priority, setPriority] = useState('normal');
const [notes, setNotes] = useState('');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h2 className="text-xl font-bold text-slate-900">Create Reorder Request</h2>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-lg">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<p className="text-slate-600">Item: <span className="font-semibold">{item.name}</span></p>
<p className="text-sm text-slate-500">Current: {item.current_quantity} | Min: {item.minimum_quantity}</p>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Quantity *</label>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(parseFloat(e.target.value) || 0)}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
min="1"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Priority</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
rows={3}
/>
</div>
<div className="flex gap-4 pt-4">
<button
onClick={onClose}
className="flex-1 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200"
>
Cancel
</button>
<button
onClick={() => onSubmit(quantity, priority, notes)}
className="flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700"
>
Create Request
</button>
</div>
</div>
</div>
</div>
);
};
export default InventoryManagementPage;

View File

@@ -0,0 +1,793 @@
import React, { useEffect, useState } from 'react';
import { Search, Plus, Edit, Wrench, AlertTriangle, CheckCircle, Clock, X, Filter, Calendar } from 'lucide-react';
import advancedRoomService, { MaintenanceRecord } from '../../features/rooms/services/advancedRoomService';
import roomService, { Room } from '../../features/rooms/services/roomService';
import userService, { User } from '../../features/auth/services/userService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { formatDate } from '../../shared/utils/format';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
const MaintenanceManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [records, setRecords] = useState<MaintenanceRecord[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingRecord, setEditingRecord] = useState<MaintenanceRecord | null>(null);
const [rooms, setRooms] = useState<Room[]>([]);
const [staffMembers, setStaffMembers] = useState<User[]>([]);
const [loadingRooms, setLoadingRooms] = useState(false);
const [loadingStaff, setLoadingStaff] = useState(false);
const [filters, setFilters] = useState({
search: '',
status: '',
maintenance_type: '',
room_id: '',
priority: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 20;
const [formData, setFormData] = useState({
room_id: 0,
maintenance_type: 'preventive',
title: '',
description: '',
scheduled_start: new Date(),
scheduled_end: null as Date | null,
assigned_to: 0,
priority: 'medium',
estimated_cost: 0,
blocks_room: true,
block_start: null as Date | null,
block_end: null as Date | null,
notes: '',
});
const maintenanceTypes = [
{ value: 'preventive', label: 'Preventive', color: 'bg-blue-100 text-blue-800' },
{ value: 'corrective', label: 'Corrective', color: 'bg-orange-100 text-orange-800' },
{ value: 'emergency', label: 'Emergency', color: 'bg-red-100 text-red-800' },
{ value: 'upgrade', label: 'Upgrade', color: 'bg-purple-100 text-purple-800' },
{ value: 'inspection', label: 'Inspection', color: 'bg-green-100 text-green-800' },
];
const statuses = [
{ value: 'scheduled', label: 'Scheduled', color: 'bg-yellow-100 text-yellow-800' },
{ value: 'in_progress', label: 'In Progress', color: 'bg-blue-100 text-blue-800' },
{ value: 'completed', label: 'Completed', color: 'bg-green-100 text-green-800' },
{ value: 'cancelled', label: 'Cancelled', color: 'bg-gray-100 text-gray-800' },
{ value: 'on_hold', label: 'On Hold', color: 'bg-orange-100 text-orange-800' },
];
const priorities = [
{ value: 'low', label: 'Low', color: 'bg-gray-100 text-gray-800' },
{ value: 'medium', label: 'Medium', color: 'bg-yellow-100 text-yellow-800' },
{ value: 'high', label: 'High', color: 'bg-orange-100 text-orange-800' },
{ value: 'urgent', label: 'Urgent', color: 'bg-red-100 text-red-800' },
];
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchRecords();
fetchRooms();
fetchStaffMembers();
}, [filters, currentPage]);
const fetchRecords = async () => {
try {
setLoading(true);
const params: any = {
page: currentPage,
limit: itemsPerPage,
};
if (filters.status) params.status = filters.status;
if (filters.maintenance_type) params.maintenance_type = filters.maintenance_type;
if (filters.room_id) params.room_id = parseInt(filters.room_id);
if (filters.priority) params.priority = filters.priority;
const response = await advancedRoomService.getMaintenanceRecords(params);
if (response.status === 'success' && response.data) {
let recordList = response.data.maintenance_records || [];
if (filters.search) {
recordList = recordList.filter((record: MaintenanceRecord) =>
record.title.toLowerCase().includes(filters.search.toLowerCase()) ||
record.description?.toLowerCase().includes(filters.search.toLowerCase()) ||
record.room_number?.toLowerCase().includes(filters.search.toLowerCase())
);
}
setRecords(recordList);
setTotalPages(response.data.pagination?.total_pages || 1);
setTotalItems(response.data.pagination?.total || 0);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load maintenance records');
} finally {
setLoading(false);
}
};
const fetchRooms = async () => {
try {
setLoadingRooms(true);
const response = await roomService.getRooms({ limit: 1000 });
if (response.data && response.data.rooms) {
setRooms(response.data.rooms);
}
} catch (error) {
console.error('Error fetching rooms:', error);
} finally {
setLoadingRooms(false);
}
};
const fetchStaffMembers = async () => {
try {
setLoadingStaff(true);
const response = await userService.getUsers({ role: 'staff', limit: 100 });
if (response.data && response.data.users) {
setStaffMembers(response.data.users);
}
} catch (error) {
console.error('Error fetching staff members:', error);
} finally {
setLoadingStaff(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.room_id) {
toast.error('Please select a room');
return;
}
if (!formData.title.trim()) {
toast.error('Please enter a title');
return;
}
try {
const dataToSubmit: any = {
room_id: formData.room_id,
maintenance_type: formData.maintenance_type,
title: formData.title,
description: formData.description || undefined,
scheduled_start: formData.scheduled_start.toISOString(),
priority: formData.priority,
blocks_room: formData.blocks_room,
notes: formData.notes || undefined,
};
if (formData.scheduled_end) {
dataToSubmit.scheduled_end = formData.scheduled_end.toISOString();
}
if (formData.assigned_to) {
dataToSubmit.assigned_to = formData.assigned_to;
}
if (formData.estimated_cost > 0) {
dataToSubmit.estimated_cost = formData.estimated_cost;
}
if (formData.blocks_room) {
if (formData.block_start) {
dataToSubmit.block_start = formData.block_start.toISOString();
}
if (formData.block_end) {
dataToSubmit.block_end = formData.block_end.toISOString();
}
}
if (editingRecord) {
await advancedRoomService.updateMaintenanceRecord(editingRecord.id, {
status: editingRecord.status,
actual_start: editingRecord.actual_start,
actual_end: editingRecord.actual_end,
completion_notes: editingRecord.completion_notes,
actual_cost: editingRecord.actual_cost,
});
toast.success('Maintenance record updated successfully');
} else {
await advancedRoomService.createMaintenanceRecord(dataToSubmit);
toast.success('Maintenance record created successfully');
}
setShowModal(false);
resetForm();
fetchRecords();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to save maintenance record');
}
};
const handleEdit = (record: MaintenanceRecord) => {
setEditingRecord(record);
// For editing, we can only update status and completion fields
setFormData({
room_id: record.room_id,
maintenance_type: record.maintenance_type,
title: record.title,
description: record.description || '',
scheduled_start: new Date(record.scheduled_start),
scheduled_end: record.scheduled_end ? new Date(record.scheduled_end) : null,
assigned_to: record.assigned_to || 0,
priority: record.priority,
estimated_cost: record.estimated_cost || 0,
blocks_room: record.blocks_room,
block_start: record.block_start ? new Date(record.block_start) : null,
block_end: record.block_end ? new Date(record.block_end) : null,
notes: record.notes || '',
});
setShowModal(true);
};
const handleStatusUpdate = async (recordId: number, newStatus: string) => {
try {
await advancedRoomService.updateMaintenanceRecord(recordId, {
status: newStatus,
});
toast.success('Maintenance status updated successfully');
fetchRecords();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to update status');
}
};
const resetForm = () => {
setEditingRecord(null);
setFormData({
room_id: 0,
maintenance_type: 'preventive',
title: '',
description: '',
scheduled_start: new Date(),
scheduled_end: null,
assigned_to: 0,
priority: 'medium',
estimated_cost: 0,
blocks_room: true,
block_start: null,
block_end: null,
notes: '',
});
};
const getStatusBadge = (status: string) => {
const statusObj = statuses.find((s) => s.value === status);
return statusObj || statuses[0];
};
const getTypeBadge = (type: string) => {
const typeObj = maintenanceTypes.find((t) => t.value === type);
return typeObj || maintenanceTypes[0];
};
const getPriorityBadge = (priority: string) => {
const priorityObj = priorities.find((p) => p.value === priority);
return priorityObj || priorities[1];
};
const getActiveRecords = () => records.filter((r) => ['scheduled', 'in_progress', 'on_hold'].includes(r.status)).length;
const getCompletedRecords = () => records.filter((r) => r.status === 'completed').length;
const getEmergencyRecords = () => records.filter((r) => r.maintenance_type === 'emergency' && ['scheduled', 'in_progress'].includes(r.status)).length;
if (loading && records.length === 0) {
return <Loading fullScreen text="Loading maintenance records..." />;
}
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Maintenance Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage room maintenance and repairs</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="w-5 h-5" />
New Maintenance
</button>
</div>
{/* Filters */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="grid grid-cols-1 md:grid-cols-5 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search maintenance..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Statuses</option>
{statuses.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
<select
value={filters.maintenance_type}
onChange={(e) => setFilters({ ...filters, maintenance_type: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Types</option>
{maintenanceTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<select
value={filters.room_id}
onChange={(e) => setFilters({ ...filters, room_id: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Rooms</option>
{rooms.map((room) => (
<option key={room.id} value={room.id}>
Room {room.room_number}
</option>
))}
</select>
<select
value={filters.priority}
onChange={(e) => setFilters({ ...filters, priority: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Priorities</option>
{priorities.map((priority) => (
<option key={priority.value} value={priority.value}>
{priority.label}
</option>
))}
</select>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Total Records</p>
<p className="text-3xl font-bold text-slate-900 mt-2">{totalItems}</p>
</div>
<Wrench className="w-12 h-12 text-amber-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Active</p>
<p className="text-3xl font-bold text-blue-600 mt-2">{getActiveRecords()}</p>
</div>
<Clock className="w-12 h-12 text-blue-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Completed</p>
<p className="text-3xl font-bold text-green-600 mt-2">{getCompletedRecords()}</p>
</div>
<CheckCircle className="w-12 h-12 text-green-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Emergency</p>
<p className="text-3xl font-bold text-red-600 mt-2">{getEmergencyRecords()}</p>
</div>
<AlertTriangle className="w-12 h-12 text-red-500" />
</div>
</div>
</div>
{/* Records Table */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b-2 border-slate-200">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Maintenance
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Room
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Scheduled
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Priority
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Assigned To
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{records.map((record) => {
const statusBadge = getStatusBadge(record.status);
const typeBadge = getTypeBadge(record.maintenance_type);
const priorityBadge = getPriorityBadge(record.priority);
return (
<tr key={record.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div>
<div className="font-semibold text-slate-900">{record.title}</div>
{record.description && (
<div className="text-sm text-slate-500 mt-1 line-clamp-2">{record.description}</div>
)}
</div>
</td>
<td className="px-6 py-4">
<div className="font-semibold text-slate-900">Room {record.room_number || record.room_id}</div>
</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${typeBadge.color}`}>
{typeBadge.label}
</span>
</td>
<td className="px-6 py-4">
<div className="text-slate-900 font-medium">
{formatDate(record.scheduled_start)}
</div>
{record.scheduled_end && (
<div className="text-xs text-slate-500 mt-1">
Until: {formatDate(record.scheduled_end)}
</div>
)}
</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusBadge.color}`}>
{statusBadge.label}
</span>
</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${priorityBadge.color}`}>
{priorityBadge.label}
</span>
</td>
<td className="px-6 py-4 text-slate-600">
{record.assigned_staff_name || 'Unassigned'}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(record)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
{record.status !== 'completed' && record.status !== 'cancelled' && (
<select
value={record.status}
onChange={(e) => handleStatusUpdate(record.id, e.target.value)}
className="px-3 py-1.5 text-sm border-2 border-slate-200 rounded-lg focus:border-amber-400 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
{statuses.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{records.length === 0 && !loading && (
<div className="text-center py-12">
<Wrench className="w-16 h-16 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 text-lg">No maintenance records found</p>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">
{editingRecord ? 'Edit Maintenance Record' : 'Create Maintenance Record'}
</h2>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Room *
</label>
<select
value={formData.room_id}
onChange={(e) => setFormData({ ...formData, room_id: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
disabled={!!editingRecord}
>
<option value={0}>Select Room</option>
{rooms.map((room) => (
<option key={room.id} value={room.id}>
Room {room.room_number} ({room.room_type?.name || 'Unknown'})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Maintenance Type *
</label>
<select
value={formData.maintenance_type}
onChange={(e) => setFormData({ ...formData, maintenance_type: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
>
{maintenanceTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Title *
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Scheduled Start *
</label>
<DatePicker
selected={formData.scheduled_start}
onChange={(date: Date) => setFormData({ ...formData, scheduled_start: date })}
showTimeSelect
dateFormat="MMMM d, yyyy h:mm aa"
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Scheduled End
</label>
<DatePicker
selected={formData.scheduled_end}
onChange={(date: Date | null) => setFormData({ ...formData, scheduled_end: date })}
showTimeSelect
dateFormat="MMMM d, yyyy h:mm aa"
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Assigned To
</label>
<select
value={formData.assigned_to}
onChange={(e) => setFormData({ ...formData, assigned_to: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
>
<option value={0}>Unassigned</option>
{staffMembers.map((staff) => (
<option key={staff.id} value={staff.id}>
{staff.full_name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Priority *
</label>
<select
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
>
{priorities.map((priority) => (
<option key={priority.value} value={priority.value}>
{priority.label}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Estimated Cost
</label>
<input
type="number"
value={formData.estimated_cost}
onChange={(e) => setFormData({ ...formData, estimated_cost: parseFloat(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
min="0"
step="0.01"
/>
</div>
<div className="flex items-center pt-8">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.blocks_room}
onChange={(e) => setFormData({ ...formData, blocks_room: e.target.checked })}
className="w-5 h-5 text-amber-600 rounded"
/>
<span className="text-slate-700 font-medium">Blocks Room</span>
</label>
</div>
</div>
{formData.blocks_room && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Block Start
</label>
<DatePicker
selected={formData.block_start}
onChange={(date: Date | null) => setFormData({ ...formData, block_start: date })}
showTimeSelect
dateFormat="MMMM d, yyyy h:mm aa"
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Block End
</label>
<DatePicker
selected={formData.block_end}
onChange={(date: Date | null) => setFormData({ ...formData, block_end: date })}
showTimeSelect
dateFormat="MMMM d, yyyy h:mm aa"
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Notes
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
rows={3}
/>
</div>
<div className="flex gap-4 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="flex-1 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all"
>
{editingRecord ? 'Update Record' : 'Create Record'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default MaintenanceManagementPage;

View File

@@ -215,6 +215,8 @@ const PageContentDashboard: React.FC = () => {
og_description: contents.home.og_description || '',
og_image: contents.home.og_image || '',
features: normalizeArray(contents.home.features),
features_section_title: contents.home.features_section_title || '',
features_section_subtitle: contents.home.features_section_subtitle || '',
amenities_section_title: contents.home.amenities_section_title || '',
amenities_section_subtitle: contents.home.amenities_section_subtitle || '',
amenities: normalizeArray(contents.home.amenities),
@@ -226,6 +228,13 @@ const PageContentDashboard: React.FC = () => {
about_preview_content: contents.home.about_preview_content || '',
about_preview_image: contents.home.about_preview_image || '',
stats: normalizeArray(contents.home.stats),
stats_section_title: contents.home.stats_section_title || '',
stats_section_subtitle: contents.home.stats_section_subtitle || '',
rooms_section_title: contents.home.rooms_section_title || '',
rooms_section_subtitle: contents.home.rooms_section_subtitle || '',
rooms_section_button_text: contents.home.rooms_section_button_text || '',
rooms_section_button_link: contents.home.rooms_section_button_link || '',
rooms_section_enabled: contents.home.rooms_section_enabled ?? true,
luxury_section_title: contents.home.luxury_section_title || '',
luxury_section_subtitle: contents.home.luxury_section_subtitle || '',
luxury_section_image: contents.home.luxury_section_image || '',
@@ -238,6 +247,9 @@ const PageContentDashboard: React.FC = () => {
luxury_testimonials: normalizeArray(contents.home.luxury_testimonials),
luxury_services_section_title: contents.home.luxury_services_section_title || '',
luxury_services_section_subtitle: contents.home.luxury_services_section_subtitle || '',
services_section_button_text: contents.home.services_section_button_text || '',
services_section_button_link: contents.home.services_section_button_link || '',
services_section_limit: contents.home.services_section_limit || 6,
luxury_experiences_section_title: contents.home.luxury_experiences_section_title || '',
luxury_experiences_section_subtitle: contents.home.luxury_experiences_section_subtitle || '',
luxury_experiences: normalizeArray(contents.home.luxury_experiences),
@@ -252,6 +264,21 @@ const PageContentDashboard: React.FC = () => {
partners_section_title: contents.home.partners_section_title || '',
partners_section_subtitle: contents.home.partners_section_subtitle || '',
partners: normalizeArray(contents.home.partners),
sections_enabled: contents.home.sections_enabled || {},
trust_badges_section_title: contents.home.trust_badges_section_title || '',
trust_badges_section_subtitle: contents.home.trust_badges_section_subtitle || '',
trust_badges: normalizeArray(contents.home.trust_badges),
trust_badges_enabled: contents.home.trust_badges_enabled ?? false,
promotions_section_title: contents.home.promotions_section_title || '',
promotions_section_subtitle: contents.home.promotions_section_subtitle || '',
promotions: normalizeArray(contents.home.promotions),
promotions_enabled: contents.home.promotions_enabled ?? false,
blog_section_title: contents.home.blog_section_title || '',
blog_section_subtitle: contents.home.blog_section_subtitle || '',
blog_posts_limit: contents.home.blog_posts_limit || 3,
blog_enabled: contents.home.blog_enabled ?? false,
hero_video_url: contents.home.hero_video_url || '',
hero_video_poster: contents.home.hero_video_poster || '',
});
}
@@ -408,7 +435,9 @@ const PageContentDashboard: React.FC = () => {
const stringFields = [
'title', 'subtitle', 'description', 'content', 'meta_title', 'meta_description', 'meta_keywords',
'og_title', 'og_description', 'og_image', 'canonical_url', 'hero_title', 'hero_subtitle', 'hero_image',
'hero_video_url', 'hero_video_poster',
'story_content', 'about_hero_image', 'mission', 'vision',
'features_section_title', 'features_section_subtitle',
'amenities_section_title', 'amenities_section_subtitle',
'testimonials_section_title', 'testimonials_section_subtitle',
'gallery_section_title', 'gallery_section_subtitle',
@@ -416,11 +445,17 @@ const PageContentDashboard: React.FC = () => {
'luxury_gallery_section_title', 'luxury_gallery_section_subtitle',
'luxury_testimonials_section_title', 'luxury_testimonials_section_subtitle',
'about_preview_title', 'about_preview_subtitle', 'about_preview_content', 'about_preview_image',
'stats_section_title', 'stats_section_subtitle',
'rooms_section_title', 'rooms_section_subtitle', 'rooms_section_button_text', 'rooms_section_button_link',
'luxury_services_section_title', 'luxury_services_section_subtitle',
'services_section_button_text', 'services_section_button_link',
'luxury_experiences_section_title', 'luxury_experiences_section_subtitle',
'awards_section_title', 'awards_section_subtitle',
'cta_title', 'cta_subtitle', 'cta_button_text', 'cta_button_link', 'cta_image',
'partners_section_title', 'partners_section_subtitle',
'trust_badges_section_title', 'trust_badges_section_subtitle',
'promotions_section_title', 'promotions_section_subtitle',
'blog_section_title', 'blog_section_subtitle',
'copyright_text', 'map_url'
];
@@ -461,6 +496,19 @@ const PageContentDashboard: React.FC = () => {
return;
}
// Handle special fields that might be numbers or booleans
if (key === 'services_section_limit' || key === 'blog_posts_limit') {
if (typeof value === 'number' && value > 0) {
cleanData[key] = value;
}
return;
}
if (key === 'rooms_section_enabled' || key === 'trust_badges_enabled' ||
key === 'promotions_enabled' || key === 'blog_enabled') {
cleanData[key] = value === true;
return;
}
// Handle other types - ensure they're valid
if (typeof value === 'number' || typeof value === 'boolean') {
cleanData[key] = value;
@@ -1895,6 +1943,31 @@ const PageContentDashboard: React.FC = () => {
<div id="stats-section" className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-extrabold text-gray-900">Statistics Section</h2>
</div>
<div className="space-y-6 mb-6">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Title</label>
<input
type="text"
value={homeData.stats_section_title || ''}
onChange={(e) => setHomeData({ ...homeData, stats_section_title: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Our Achievements"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Subtitle</label>
<input
type="text"
value={homeData.stats_section_subtitle || ''}
onChange={(e) => setHomeData({ ...homeData, stats_section_subtitle: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Numbers that speak for themselves"
/>
</div>
</div>
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-semibold text-gray-900">Statistics</h3>
<button
type="button"
onClick={() => {
@@ -1976,6 +2049,185 @@ const PageContentDashboard: React.FC = () => {
</div>
</div>
{/* Rooms Section */}
<div id="rooms-section" className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h2 className="text-2xl font-extrabold text-gray-900 mb-6">Rooms Section</h2>
<div className="space-y-6">
<div className="flex items-center gap-4 mb-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={homeData.rooms_section_enabled !== false}
onChange={(e) => setHomeData({ ...homeData, rooms_section_enabled: e.target.checked })}
className="w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
/>
<span className="text-sm font-semibold text-gray-700">Enable Rooms Section</span>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Title</label>
<input
type="text"
value={homeData.rooms_section_title || homeData.hero_title || ''}
onChange={(e) => setHomeData({ ...homeData, rooms_section_title: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Featured & Newest Rooms"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Subtitle</label>
<input
type="text"
value={homeData.rooms_section_subtitle || homeData.hero_subtitle || ''}
onChange={(e) => setHomeData({ ...homeData, rooms_section_subtitle: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Discover our most popular accommodations"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Button Text</label>
<input
type="text"
value={homeData.rooms_section_button_text || 'View All Rooms'}
onChange={(e) => setHomeData({ ...homeData, rooms_section_button_text: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="View All Rooms"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Button Link</label>
<input
type="text"
value={homeData.rooms_section_button_link || '/rooms'}
onChange={(e) => setHomeData({ ...homeData, rooms_section_button_link: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="/rooms"
/>
</div>
</div>
</div>
</div>
{/* Features Section */}
<div id="features-section" className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h2 className="text-2xl font-extrabold text-gray-900 mb-6">Features Section</h2>
<div className="space-y-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Title</label>
<input
type="text"
value={homeData.features_section_title || ''}
onChange={(e) => setHomeData({ ...homeData, features_section_title: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Why Choose Us"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Subtitle</label>
<input
type="text"
value={homeData.features_section_subtitle || ''}
onChange={(e) => setHomeData({ ...homeData, features_section_subtitle: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Experience excellence in every detail"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Features</h3>
<button
onClick={() => {
setHomeData((prevData) => {
const current = Array.isArray(prevData.features) ? prevData.features : [];
return {
...prevData,
features: [...current, { icon: 'Star', title: '', description: '', image: '' }]
};
});
}}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-purple-500 to-purple-600 text-white rounded-lg font-semibold hover:from-purple-600 hover:to-purple-700 transition-all"
>
<Plus className="w-4 h-4" />
Add Feature
</button>
</div>
<div className="space-y-4">
{Array.isArray(homeData.features) && homeData.features.map((feature, index) => (
<div key={`feature-${index}-${feature.title || index}`} className="p-6 border-2 border-gray-200 rounded-xl space-y-4">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-gray-900">Feature {index + 1}</h4>
<button
type="button"
onClick={() => {
setHomeData((prevData) => {
const currentFeatures = Array.isArray(prevData.features) ? prevData.features : [];
const updated = currentFeatures.filter((_, i) => i !== index);
return { ...prevData, features: updated };
});
}}
className="text-red-600 hover:text-red-700 p-1"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
<div>
<IconPicker
value={feature?.icon || ''}
onChange={(iconName) => {
setHomeData((prevData) => {
const currentFeatures = Array.isArray(prevData.features) ? [...prevData.features] : [];
currentFeatures[index] = { ...currentFeatures[index], icon: iconName };
return { ...prevData, features: currentFeatures };
});
}}
label="Icon"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Title</label>
<input
type="text"
value={feature?.title || ''}
onChange={(e) => {
setHomeData((prevData) => {
const currentFeatures = Array.isArray(prevData.features) ? [...prevData.features] : [];
currentFeatures[index] = { ...currentFeatures[index], title: e.target.value };
return { ...prevData, features: currentFeatures };
});
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Feature Title"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Description</label>
<textarea
value={feature?.description || ''}
onChange={(e) => {
setHomeData((prevData) => {
const currentFeatures = Array.isArray(prevData.features) ? [...prevData.features] : [];
currentFeatures[index] = { ...currentFeatures[index], description: e.target.value };
return { ...prevData, features: currentFeatures };
});
}}
rows={2}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Feature description"
/>
</div>
</div>
))}
{(!homeData.features || homeData.features.length === 0) && (
<p className="text-gray-500 text-center py-8">No features added yet. Click "Add Feature" to get started.</p>
)}
</div>
</div>
{/* Luxury Services Section */}
<div id="luxury-services-section" className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h2 className="text-2xl font-extrabold text-gray-900 mb-6">Luxury Services Section</h2>
@@ -1994,7 +2246,7 @@ const PageContentDashboard: React.FC = () => {
</div>
{/* Section Title and Subtitle */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Title</label>
<input
@@ -2018,6 +2270,40 @@ const PageContentDashboard: React.FC = () => {
<p className="text-xs text-gray-500 mt-1">Subtitle displayed below the title on homepage</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Button Text</label>
<input
type="text"
value={homeData.services_section_button_text || 'View All Services'}
onChange={(e) => setHomeData({ ...homeData, services_section_button_text: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="View All Services"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Button Link</label>
<input
type="text"
value={homeData.services_section_button_link || '/services'}
onChange={(e) => setHomeData({ ...homeData, services_section_button_link: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="/services"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Services to Display</label>
<input
type="number"
min="1"
max="12"
value={homeData.services_section_limit || 6}
onChange={(e) => setHomeData({ ...homeData, services_section_limit: parseInt(e.target.value) || 6 })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="6"
/>
</div>
</div>
</div>
{/* Services are now managed in Service Management page only */}
@@ -2568,6 +2854,460 @@ const PageContentDashboard: React.FC = () => {
</div>
</div>
{/* Section Visibility Toggles */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h2 className="text-2xl font-extrabold text-gray-900 mb-6">Section Visibility</h2>
<p className="text-gray-600 mb-6">Toggle sections on/off to control what appears on the homepage</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[
{ key: 'rooms', label: 'Rooms Section' },
{ key: 'features', label: 'Features Section' },
{ key: 'luxury', label: 'Luxury Section' },
{ key: 'gallery', label: 'Gallery Section' },
{ key: 'testimonials', label: 'Testimonials Section' },
{ key: 'stats', label: 'Statistics Section' },
{ key: 'amenities', label: 'Amenities Section' },
{ key: 'about_preview', label: 'About Preview Section' },
{ key: 'services', label: 'Services Section' },
{ key: 'experiences', label: 'Experiences Section' },
{ key: 'awards', label: 'Awards Section' },
{ key: 'cta', label: 'CTA Section' },
{ key: 'partners', label: 'Partners Section' },
].map((section) => (
<label key={section.key} className="flex items-center gap-3 p-4 border-2 border-gray-200 rounded-xl cursor-pointer hover:border-purple-300 transition-colors">
<input
type="checkbox"
checked={(homeData.sections_enabled?.[section.key as keyof typeof homeData.sections_enabled] ?? true)}
onChange={(e) => {
setHomeData({
...homeData,
sections_enabled: {
...homeData.sections_enabled,
[section.key]: e.target.checked,
},
});
}}
className="w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
/>
<span className="text-sm font-semibold text-gray-700">{section.label}</span>
</label>
))}
</div>
</div>
{/* Trust Badges Section */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-extrabold text-gray-900">Trust Badges Section</h2>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={homeData.trust_badges_enabled ?? false}
onChange={(e) => setHomeData({ ...homeData, trust_badges_enabled: e.target.checked })}
className="w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
/>
<span className="text-sm font-semibold text-gray-700">Enable Trust Badges</span>
</label>
</div>
{homeData.trust_badges_enabled && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Title</label>
<input
type="text"
value={homeData.trust_badges_section_title || ''}
onChange={(e) => setHomeData({ ...homeData, trust_badges_section_title: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Trusted & Certified"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Subtitle</label>
<input
type="text"
value={homeData.trust_badges_section_subtitle || ''}
onChange={(e) => setHomeData({ ...homeData, trust_badges_section_subtitle: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Recognized for excellence"
/>
</div>
</div>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Trust Badges</h3>
<button
type="button"
onClick={() => {
const current = Array.isArray(homeData.trust_badges) ? homeData.trust_badges : [];
setHomeData({
...homeData,
trust_badges: [...current, { name: '', logo: '', description: '', link: '' }]
});
}}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-lg font-semibold hover:from-green-600 hover:to-green-700 transition-all"
>
<Plus className="w-4 h-4" />
Add Badge
</button>
</div>
<div className="space-y-4">
{Array.isArray(homeData.trust_badges) && homeData.trust_badges.map((badge, index) => (
<div key={`badge-${index}`} className="p-6 border-2 border-gray-200 rounded-xl space-y-4">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-gray-900">Badge {index + 1}</h4>
<button
type="button"
onClick={() => {
const current = Array.isArray(homeData.trust_badges) ? homeData.trust_badges : [];
const updated = current.filter((_, i) => i !== index);
setHomeData({ ...homeData, trust_badges: updated });
}}
className="text-red-600 hover:text-red-700 p-1"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Name</label>
<input
type="text"
value={badge?.name || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], name: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Certification Name"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Link (Optional)</label>
<input
type="url"
value={badge?.link || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], link: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="https://example.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Description (Optional)</label>
<textarea
value={badge?.description || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], description: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
rows={2}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Brief description"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Logo</label>
<div className="flex gap-2">
<input
type="url"
value={badge?.logo || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], logo: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
className="flex-1 px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Logo URL or upload"
/>
<label className="px-5 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-bold hover:from-green-700 hover:to-green-800 transition-all cursor-pointer flex items-center gap-2">
<Upload className="w-4 h-4" />
Upload
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
await handlePageContentImageUpload(file, (imageUrl) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], logo: imageUrl };
setHomeData({ ...homeData, trust_badges: current });
});
}
}}
className="hidden"
/>
</label>
</div>
</div>
</div>
))}
{(!homeData.trust_badges || homeData.trust_badges.length === 0) && (
<p className="text-gray-500 text-center py-8">No trust badges added yet. Click "Add Badge" to get started.</p>
)}
</div>
</div>
)}
</div>
{/* Promotions Section */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-extrabold text-gray-900">Promotions & Special Offers</h2>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={homeData.promotions_enabled ?? false}
onChange={(e) => setHomeData({ ...homeData, promotions_enabled: e.target.checked })}
className="w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
/>
<span className="text-sm font-semibold text-gray-700">Enable Promotions</span>
</label>
</div>
{homeData.promotions_enabled && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Title</label>
<input
type="text"
value={homeData.promotions_section_title || ''}
onChange={(e) => setHomeData({ ...homeData, promotions_section_title: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Special Offers"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Subtitle</label>
<input
type="text"
value={homeData.promotions_section_subtitle || ''}
onChange={(e) => setHomeData({ ...homeData, promotions_section_subtitle: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Limited time offers"
/>
</div>
</div>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Promotions</h3>
<button
type="button"
onClick={() => {
const current = Array.isArray(homeData.promotions) ? homeData.promotions : [];
setHomeData({
...homeData,
promotions: [...current, { title: '', description: '', image: '', discount: '', valid_until: '', link: '', button_text: '' }]
});
}}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-orange-500 to-orange-600 text-white rounded-lg font-semibold hover:from-orange-600 hover:to-orange-700 transition-all"
>
<Plus className="w-4 h-4" />
Add Promotion
</button>
</div>
<div className="space-y-4">
{Array.isArray(homeData.promotions) && homeData.promotions.map((promo, index) => (
<div key={`promo-${index}`} className="p-6 border-2 border-gray-200 rounded-xl space-y-4">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-gray-900">Promotion {index + 1}</h4>
<button
type="button"
onClick={() => {
const current = Array.isArray(homeData.promotions) ? homeData.promotions : [];
const updated = current.filter((_, i) => i !== index);
setHomeData({ ...homeData, promotions: updated });
}}
className="text-red-600 hover:text-red-700 p-1"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Title</label>
<input
type="text"
value={promo?.title || ''}
onChange={(e) => {
const current = Array.isArray(homeData.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], title: e.target.value };
setHomeData({ ...homeData, promotions: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Promotion Title"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Discount</label>
<input
type="text"
value={promo?.discount || ''}
onChange={(e) => {
const current = Array.isArray(homeData.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], discount: e.target.value };
setHomeData({ ...homeData, promotions: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="20% OFF"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Description</label>
<textarea
value={promo?.description || ''}
onChange={(e) => {
const current = Array.isArray(homeData.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], description: e.target.value };
setHomeData({ ...homeData, promotions: current });
}}
rows={2}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Promotion description"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Valid Until</label>
<input
type="date"
value={promo?.valid_until || ''}
onChange={(e) => {
const current = Array.isArray(homeData.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], valid_until: e.target.value };
setHomeData({ ...homeData, promotions: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Button Text</label>
<input
type="text"
value={promo?.button_text || ''}
onChange={(e) => {
const current = Array.isArray(homeData.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], button_text: e.target.value };
setHomeData({ ...homeData, promotions: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Book Now"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Link</label>
<input
type="url"
value={promo?.link || ''}
onChange={(e) => {
const current = Array.isArray(homeData.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], link: e.target.value };
setHomeData({ ...homeData, promotions: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="/rooms or /booking"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Image</label>
<div className="flex gap-2">
<input
type="url"
value={promo?.image || ''}
onChange={(e) => {
const current = Array.isArray(homeData.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], image: e.target.value };
setHomeData({ ...homeData, promotions: current });
}}
className="flex-1 px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Image URL or upload"
/>
<label className="px-5 py-2 bg-gradient-to-r from-orange-600 to-orange-700 text-white rounded-lg font-bold hover:from-orange-700 hover:to-orange-800 transition-all cursor-pointer flex items-center gap-2">
<Upload className="w-4 h-4" />
Upload
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
await handlePageContentImageUpload(file, (imageUrl) => {
const current = Array.isArray(homeData.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], image: imageUrl };
setHomeData({ ...homeData, promotions: current });
});
}
}}
className="hidden"
/>
</label>
</div>
</div>
</div>
))}
{(!homeData.promotions || homeData.promotions.length === 0) && (
<p className="text-gray-500 text-center py-8">No promotions added yet. Click "Add Promotion" to get started.</p>
)}
</div>
</div>
)}
</div>
{/* Hero Video Section */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h2 className="text-2xl font-extrabold text-gray-900 mb-6">Hero Video (Optional)</h2>
<div className="space-y-6">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Video URL</label>
<input
type="url"
value={homeData.hero_video_url || ''}
onChange={(e) => setHomeData({ ...homeData, hero_video_url: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="https://youtube.com/watch?v=... or direct video URL"
/>
<p className="text-xs text-gray-500 mt-1">Supports YouTube, Vimeo, or direct video URLs</p>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Video Poster Image (Optional)</label>
<div className="flex gap-2">
<input
type="url"
value={homeData.hero_video_poster || ''}
onChange={(e) => setHomeData({ ...homeData, hero_video_poster: e.target.value })}
className="flex-1 px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Poster image URL or upload"
/>
<label className="px-5 py-3 bg-gradient-to-r from-purple-600 to-purple-700 text-white rounded-xl font-bold hover:from-purple-700 hover:to-purple-800 transition-all cursor-pointer flex items-center gap-2">
<Upload className="w-5 h-5" />
Upload
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
await handlePageContentImageUpload(file, (imageUrl) => {
setHomeData({ ...homeData, hero_video_poster: imageUrl });
});
}
}}
className="hidden"
/>
</label>
</div>
<p className="text-xs text-gray-500 mt-1">Thumbnail image shown before video plays</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex justify-end">

View File

@@ -0,0 +1,620 @@
import React, { useEffect, useState } from 'react';
import { Search, Plus, Edit, Calendar, Clock, Users, X, Filter } from 'lucide-react';
import staffShiftService, { StaffShift } from '../../features/staffShifts/services/staffShiftService';
import userService, { User } from '../../features/auth/services/userService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { formatDate } from '../../shared/utils/format';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
const StaffShiftManagementPage: React.FC = () => {
const [shifts, setShifts] = useState<StaffShift[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingShift, setEditingShift] = useState<StaffShift | null>(null);
const [staffMembers, setStaffMembers] = useState<User[]>([]);
const [filters, setFilters] = useState({
search: '',
status: '',
staff_id: '',
shift_date: '',
department: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 20;
const [formData, setFormData] = useState({
staff_id: 0,
shift_date: new Date(),
shift_type: 'full_day',
start_time: '08:00',
end_time: '20:00',
status: 'scheduled',
break_duration_minutes: 30,
department: '',
notes: '',
});
const shiftTypes = [
{ value: 'morning', label: 'Morning (6 AM - 2 PM)' },
{ value: 'afternoon', label: 'Afternoon (2 PM - 10 PM)' },
{ value: 'night', label: 'Night (10 PM - 6 AM)' },
{ value: 'full_day', label: 'Full Day (8 AM - 8 PM)' },
{ value: 'custom', label: 'Custom' },
];
const statuses = [
{ value: 'scheduled', label: 'Scheduled', color: 'bg-yellow-100 text-yellow-800' },
{ value: 'in_progress', label: 'In Progress', color: 'bg-blue-100 text-blue-800' },
{ value: 'completed', label: 'Completed', color: 'bg-green-100 text-green-800' },
{ value: 'cancelled', label: 'Cancelled', color: 'bg-gray-100 text-gray-800' },
{ value: 'no_show', label: 'No Show', color: 'bg-red-100 text-red-800' },
];
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchShifts();
fetchStaffMembers();
}, [filters, currentPage]);
const fetchShifts = async () => {
try {
setLoading(true);
const params: any = {
page: currentPage,
limit: itemsPerPage,
};
if (filters.status) params.status = filters.status;
if (filters.staff_id) params.staff_id = parseInt(filters.staff_id);
if (filters.shift_date) params.shift_date = filters.shift_date;
if (filters.department) params.department = filters.department;
const response = await staffShiftService.getShifts(params);
if (response.status === 'success' && response.data) {
let shiftList = response.data.shifts || [];
if (filters.search) {
shiftList = shiftList.filter((shift: StaffShift) =>
shift.staff_name?.toLowerCase().includes(filters.search.toLowerCase()) ||
shift.department?.toLowerCase().includes(filters.search.toLowerCase())
);
}
setShifts(shiftList);
setTotalPages(response.data.pagination?.total_pages || 1);
setTotalItems(response.data.pagination?.total || 0);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load shifts');
} finally {
setLoading(false);
}
};
const fetchStaffMembers = async () => {
try {
// Fetch both staff and housekeeping users
const [staffResponse, housekeepingResponse] = await Promise.all([
userService.getUsers({ role: 'staff', limit: 100 }),
userService.getUsers({ role: 'housekeeping', limit: 100 })
]);
const allUsers: User[] = [];
if (staffResponse.data && staffResponse.data.users) {
allUsers.push(...staffResponse.data.users);
}
if (housekeepingResponse.data && housekeepingResponse.data.users) {
allUsers.push(...housekeepingResponse.data.users);
}
setStaffMembers(allUsers);
} catch (error) {
console.error('Error fetching staff members:', error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.staff_id) {
toast.error('Please select a staff member or housekeeping user');
return;
}
try {
const dataToSubmit: any = {
staff_id: formData.staff_id,
shift_date: formData.shift_date.toISOString(),
shift_type: formData.shift_type,
start_time: formData.start_time,
end_time: formData.end_time,
status: formData.status,
break_duration_minutes: formData.break_duration_minutes,
};
if (formData.department) {
dataToSubmit.department = formData.department;
}
if (formData.notes) {
dataToSubmit.notes = formData.notes;
}
if (editingShift) {
await staffShiftService.updateShift(editingShift.id, dataToSubmit);
toast.success('Shift updated successfully');
} else {
await staffShiftService.createShift(dataToSubmit);
toast.success('Shift created successfully');
}
setShowModal(false);
resetForm();
fetchShifts();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to save shift');
}
};
const handleEdit = (shift: StaffShift) => {
setEditingShift(shift);
const shiftDate = shift.shift_date ? new Date(shift.shift_date) : new Date();
setFormData({
staff_id: shift.staff_id,
shift_date: shiftDate,
shift_type: shift.shift_type,
start_time: shift.start_time || '08:00',
end_time: shift.end_time || '20:00',
status: shift.status,
break_duration_minutes: shift.break_duration_minutes || 30,
department: shift.department || '',
notes: shift.notes || '',
});
setShowModal(true);
};
const handleStatusUpdate = async (shiftId: number, newStatus: string) => {
try {
await staffShiftService.updateShift(shiftId, {
status: newStatus,
});
toast.success('Shift status updated successfully');
fetchShifts();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to update status');
}
};
const resetForm = () => {
setEditingShift(null);
setFormData({
staff_id: 0,
shift_date: new Date(),
shift_type: 'full_day',
start_time: '08:00',
end_time: '20:00',
status: 'scheduled',
break_duration_minutes: 30,
department: '',
notes: '',
});
};
const getStatusBadge = (status: string) => {
const statusObj = statuses.find((s) => s.value === status);
return statusObj || statuses[0];
};
const getScheduledShifts = () => shifts.filter((s) => s.status === 'scheduled').length;
const getCompletedShifts = () => shifts.filter((s) => s.status === 'completed').length;
const getInProgressShifts = () => shifts.filter((s) => s.status === 'in_progress').length;
if (loading && shifts.length === 0) {
return <Loading fullScreen text="Loading shifts..." />;
}
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Staff Shift Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage staff and housekeeping schedules and shifts</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="w-5 h-5" />
New Shift
</button>
</div>
{/* Filters */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="grid grid-cols-1 md:grid-cols-5 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search shifts..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Statuses</option>
{statuses.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
<select
value={filters.staff_id}
onChange={(e) => setFilters({ ...filters, staff_id: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Staff / Housekeeping</option>
{staffMembers.map((staff) => (
<option key={staff.id} value={staff.id}>
{staff.full_name}
</option>
))}
</select>
<input
type="date"
value={filters.shift_date}
onChange={(e) => setFilters({ ...filters, shift_date: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
placeholder="Shift Date"
/>
<input
type="text"
value={filters.department}
onChange={(e) => setFilters({ ...filters, department: e.target.value })}
placeholder="Department"
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Total Shifts</p>
<p className="text-3xl font-bold text-slate-900 mt-2">{totalItems}</p>
</div>
<Calendar className="w-12 h-12 text-amber-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Scheduled</p>
<p className="text-3xl font-bold text-yellow-600 mt-2">{getScheduledShifts()}</p>
</div>
<Clock className="w-12 h-12 text-yellow-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">In Progress</p>
<p className="text-3xl font-bold text-blue-600 mt-2">{getInProgressShifts()}</p>
</div>
<Users className="w-12 h-12 text-blue-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Completed</p>
<p className="text-3xl font-bold text-green-600 mt-2">{getCompletedShifts()}</p>
</div>
<Clock className="w-12 h-12 text-green-500" />
</div>
</div>
</div>
{/* Shifts Table */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b-2 border-slate-200">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Staff Member
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Shift Type
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Time
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Department
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{shifts.map((shift) => {
const statusBadge = getStatusBadge(shift.status);
return (
<tr key={shift.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div className="font-semibold text-slate-900">{shift.staff_name || `Staff #${shift.staff_id}`}</div>
</td>
<td className="px-6 py-4">
<div className="text-slate-900 font-medium">
{formatDate(shift.shift_date)}
</div>
</td>
<td className="px-6 py-4">
<span className="px-3 py-1 rounded-full text-sm font-medium bg-slate-100 text-slate-800">
{shiftTypes.find((t) => t.value === shift.shift_type)?.label || shift.shift_type}
</span>
</td>
<td className="px-6 py-4">
<div className="text-slate-900 font-medium">
{shift.start_time} - {shift.end_time}
</div>
{shift.break_duration_minutes && (
<div className="text-xs text-slate-500 mt-1">
Break: {shift.break_duration_minutes} min
</div>
)}
</td>
<td className="px-6 py-4 text-slate-600">
{shift.department || '—'}
</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusBadge.color}`}>
{statusBadge.label}
</span>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(shift)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<select
value={shift.status}
onChange={(e) => handleStatusUpdate(shift.id, e.target.value)}
className="px-3 py-1.5 text-sm border-2 border-slate-200 rounded-lg focus:border-amber-400 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
{statuses.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{shifts.length === 0 && !loading && (
<div className="text-center py-12">
<Calendar className="w-16 h-16 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 text-lg">No shifts found</p>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">
{editingShift ? 'Edit Shift' : 'Create Shift'}
</h2>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Staff Member / Housekeeping *</label>
<select
value={formData.staff_id}
onChange={(e) => setFormData({ ...formData, staff_id: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
disabled={!!editingShift}
>
<option value={0}>Select Staff or Housekeeping</option>
{staffMembers.map((staff) => (
<option key={staff.id} value={staff.id}>
{staff.full_name} ({staff.role})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Shift Date *</label>
<DatePicker
selected={formData.shift_date}
onChange={(date: Date) => setFormData({ ...formData, shift_date: date })}
dateFormat="MMMM d, yyyy"
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Shift Type *</label>
<select
value={formData.shift_type}
onChange={(e) => setFormData({ ...formData, shift_type: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
>
{shiftTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Status *</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
>
{statuses.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Start Time *</label>
<input
type="time"
value={formData.start_time}
onChange={(e) => setFormData({ ...formData, start_time: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">End Time *</label>
<input
type="time"
value={formData.end_time}
onChange={(e) => setFormData({ ...formData, end_time: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Break (minutes)</label>
<input
type="number"
value={formData.break_duration_minutes}
onChange={(e) => setFormData({ ...formData, break_duration_minutes: parseInt(e.target.value) || 30 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
min="0"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Department</label>
<input
type="text"
value={formData.department}
onChange={(e) => setFormData({ ...formData, department: e.target.value })}
placeholder="e.g., reception, housekeeping"
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Notes</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
rows={3}
/>
</div>
<div className="flex gap-4 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="flex-1 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all"
>
{editingShift ? 'Update Shift' : 'Create Shift'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default StaffShiftManagementPage;

View File

@@ -0,0 +1,232 @@
import React, { useEffect, useState } from 'react';
import { Calendar, Clock, CheckCircle, AlertCircle } from 'lucide-react';
import staffShiftService, { StaffShift, StaffTask } from '../../features/staffShifts/services/staffShiftService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import { formatDate } from '../../shared/utils/format';
const HousekeepingShiftViewPage: React.FC = () => {
const [shifts, setShifts] = useState<StaffShift[]>([]);
const [tasks, setTasks] = useState<StaffTask[]>([]);
const [loading, setLoading] = useState(true);
const [selectedDate, setSelectedDate] = useState(new Date());
useEffect(() => {
fetchShifts();
fetchTasks();
}, [selectedDate]);
const fetchShifts = async () => {
try {
setLoading(true);
const dateStr = selectedDate.toISOString().split('T')[0];
const response = await staffShiftService.getShifts({
shift_date: dateStr,
limit: 100,
});
if (response.status === 'success' && response.data) {
setShifts(response.data.shifts || []);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load shifts');
} finally {
setLoading(false);
}
};
const fetchTasks = async () => {
try {
const response = await staffShiftService.getTasks({
limit: 100,
});
if (response.status === 'success' && response.data) {
setTasks(response.data.tasks || []);
}
} catch (error) {
console.error('Error fetching tasks:', error);
}
};
const handleStatusUpdate = async (shiftId: number, newStatus: string) => {
try {
await staffShiftService.updateShift(shiftId, {
status: newStatus,
});
toast.success('Shift status updated successfully');
fetchShifts();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to update status');
}
};
if (loading && shifts.length === 0) {
return <Loading fullScreen text="Loading your shifts..." />;
}
const getUpcomingShifts = () => shifts.filter((s) => s.status === 'scheduled').length;
const getCompletedShifts = () => shifts.filter((s) => s.status === 'completed').length;
const getPendingTasks = () => tasks.filter((t) => ['pending', 'assigned'].includes(t.status)).length;
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-[#d4af37] to-[#c9a227] rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
My Shifts
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">View and manage your shift schedule</p>
</div>
<input
type="date"
value={selectedDate.toISOString().split('T')[0]}
onChange={(e) => setSelectedDate(new Date(e.target.value))}
className="px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-[#d4af37] focus:ring-4 focus:ring-yellow-100"
/>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Upcoming Shifts</p>
<p className="text-3xl font-bold text-[#d4af37] mt-2">{getUpcomingShifts()}</p>
</div>
<Calendar className="w-12 h-12 text-[#d4af37]" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Completed</p>
<p className="text-3xl font-bold text-green-600 mt-2">{getCompletedShifts()}</p>
</div>
<CheckCircle className="w-12 h-12 text-green-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Pending Tasks</p>
<p className="text-3xl font-bold text-orange-600 mt-2">{getPendingTasks()}</p>
</div>
<AlertCircle className="w-12 h-12 text-orange-500" />
</div>
</div>
</div>
{/* Shifts List */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="p-6">
<h2 className="text-xl font-bold text-slate-900 mb-4">My Shifts</h2>
{shifts.length === 0 ? (
<div className="text-center py-12">
<Calendar className="w-16 h-16 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 text-lg">No shifts scheduled</p>
</div>
) : (
<div className="space-y-4">
{shifts.map((shift) => (
<div
key={shift.id}
className="bg-gradient-to-r from-slate-50 to-white border border-slate-200 rounded-xl p-6 hover:shadow-lg transition-all"
>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold text-slate-900">
{formatDate(shift.shift_date)}
</h3>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
shift.status === 'completed' ? 'bg-green-100 text-green-800' :
shift.status === 'in_progress' ? 'bg-blue-100 text-blue-800' :
shift.status === 'scheduled' ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'
}`}>
{shift.status.replace('_', ' ').toUpperCase()}
</span>
</div>
<div className="flex items-center gap-4 text-slate-600">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>{shift.start_time} - {shift.end_time}</span>
</div>
{shift.department && (
<span className="px-2 py-1 bg-slate-100 rounded text-sm">{shift.department}</span>
)}
</div>
{shift.notes && (
<p className="text-slate-600 mt-2 text-sm">{shift.notes}</p>
)}
</div>
{shift.status === 'scheduled' && (
<select
value={shift.status}
onChange={(e) => handleStatusUpdate(shift.id, e.target.value)}
className="px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-[#d4af37] cursor-pointer"
>
<option value="scheduled">Scheduled</option>
<option value="in_progress">Start Shift</option>
<option value="cancelled">Cancel</option>
</select>
)}
{shift.status === 'in_progress' && (
<button
onClick={() => handleStatusUpdate(shift.id, 'completed')}
className="px-4 py-2 bg-green-600 text-white rounded-xl hover:bg-green-700 transition-colors"
>
End Shift
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Tasks List */}
{tasks.length > 0 && (
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="p-6">
<h2 className="text-xl font-bold text-slate-900 mb-4">My Tasks</h2>
<div className="space-y-3">
{tasks.slice(0, 5).map((task) => (
<div
key={task.id}
className="bg-slate-50 border border-slate-200 rounded-lg p-4"
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-slate-900">{task.title}</h4>
{task.description && (
<p className="text-sm text-slate-600 mt-1">{task.description}</p>
)}
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
task.priority === 'urgent' ? 'bg-red-100 text-red-800' :
task.priority === 'high' ? 'bg-orange-100 text-orange-800' :
'bg-blue-100 text-blue-800'
}`}>
{task.priority.toUpperCase()}
</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default HousekeepingShiftViewPage;

View File

@@ -1,19 +1,141 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import {
RefreshCw,
Clock,
CheckCircle,
AlertCircle,
Calendar,
Users
} from 'lucide-react';
import HousekeepingManagement from '../../features/hotel_services/components/HousekeepingManagement';
import advancedRoomService from '../../features/rooms/services/advancedRoomService';
const HousekeepingTasksPage: React.FC = () => {
const [stats, setStats] = useState({
total: 0,
pending: 0,
in_progress: 0,
completed: 0,
today: 0,
});
const [loadingStats, setLoadingStats] = useState(true);
useEffect(() => {
fetchStats();
// Refresh stats every 30 seconds
const interval = setInterval(fetchStats, 30000);
return () => clearInterval(interval);
}, []);
const fetchStats = async () => {
try {
setLoadingStats(true);
const response = await advancedRoomService.getHousekeepingTasks({
limit: 1000, // Get all tasks for stats
});
if (response.status === 'success' && response.data) {
const tasks = response.data.tasks || [];
const today = new Date().toISOString().split('T')[0];
setStats({
total: tasks.length,
pending: tasks.filter((t: any) => t.status === 'pending').length,
in_progress: tasks.filter((t: any) => t.status === 'in_progress').length,
completed: tasks.filter((t: any) => t.status === 'completed').length,
today: tasks.filter((t: any) => {
if (!t.scheduled_time) return false;
const taskDate = new Date(t.scheduled_time).toISOString().split('T')[0];
return taskDate === today;
}).length,
});
}
} catch (error) {
console.error('Error fetching stats:', error);
} finally {
setLoadingStats(false);
}
};
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900">My Housekeeping Tasks</h1>
<p className="mt-1 text-sm text-gray-500">
View and manage your assigned housekeeping tasks
</p>
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Housekeeping Tasks
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage and track your cleaning tasks</p>
</div>
<button
onClick={fetchStats}
disabled={loadingStats}
className="flex items-center gap-2 px-4 py-2 bg-white border-2 border-slate-200 rounded-xl hover:border-amber-400 transition-colors disabled:opacity-50"
title="Refresh"
>
<RefreshCw className={`w-5 h-5 ${loadingStats ? 'animate-spin' : ''}`} />
<span className="text-sm font-medium">Refresh</span>
</button>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Total Tasks</p>
<p className="text-3xl font-bold text-slate-900 mt-2">{stats.total}</p>
</div>
<Calendar className="w-12 h-12 text-amber-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Pending</p>
<p className="text-3xl font-bold text-yellow-600 mt-2">{stats.pending}</p>
</div>
<Clock className="w-12 h-12 text-yellow-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">In Progress</p>
<p className="text-3xl font-bold text-blue-600 mt-2">{stats.in_progress}</p>
</div>
<Users className="w-12 h-12 text-blue-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Completed</p>
<p className="text-3xl font-bold text-green-600 mt-2">{stats.completed}</p>
</div>
<CheckCircle className="w-12 h-12 text-green-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Today's Tasks</p>
<p className="text-3xl font-bold text-purple-600 mt-2">{stats.today}</p>
</div>
<AlertCircle className="w-12 h-12 text-purple-500" />
</div>
</div>
</div>
{/* Enhanced Housekeeping Management Component */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<HousekeepingManagement />
</div>
<HousekeepingManagement />
</div>
);
};
export default HousekeepingTasksPage;

View File

@@ -0,0 +1,601 @@
import React, { useEffect, useState } from 'react';
import { Search, Plus, Edit, Package, AlertTriangle, Eye, X } from 'lucide-react';
import inventoryService, { InventoryItem } from '../../features/inventory/services/inventoryService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { formatDate } from '../../shared/utils/format';
const InventoryViewPage: React.FC = () => {
const [items, setItems] = useState<InventoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
const [viewingItem, setViewingItem] = useState<InventoryItem | null>(null);
const [lowStockItems, setLowStockItems] = useState<InventoryItem[]>([]);
const [filters, setFilters] = useState({
search: '',
category: '',
low_stock: false,
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 20;
const [formData, setFormData] = useState({
name: '',
description: '',
category: 'cleaning_supplies',
unit: 'piece',
minimum_quantity: 0,
maximum_quantity: 0,
reorder_quantity: 0,
unit_cost: 0,
supplier: '',
supplier_contact: '',
storage_location: '',
barcode: '',
sku: '',
notes: '',
});
const categories = [
{ value: 'cleaning_supplies', label: 'Cleaning Supplies' },
{ value: 'linens', label: 'Linens' },
{ value: 'toiletries', label: 'Toiletries' },
{ value: 'amenities', label: 'Amenities' },
{ value: 'maintenance', label: 'Maintenance' },
{ value: 'food_beverage', label: 'Food & Beverage' },
{ value: 'other', label: 'Other' },
];
const units = [
{ value: 'piece', label: 'Piece' },
{ value: 'box', label: 'Box' },
{ value: 'bottle', label: 'Bottle' },
{ value: 'roll', label: 'Roll' },
{ value: 'pack', label: 'Pack' },
{ value: 'liter', label: 'Liter' },
{ value: 'kilogram', label: 'Kilogram' },
{ value: 'meter', label: 'Meter' },
{ value: 'other', label: 'Other' },
];
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchItems();
fetchLowStockItems();
}, [filters, currentPage]);
const fetchItems = async () => {
try {
setLoading(true);
const params: any = {
page: currentPage,
limit: itemsPerPage,
};
if (filters.category) params.category = filters.category;
if (filters.low_stock) params.low_stock = true;
const response = await inventoryService.getInventoryItems(params);
if (response.status === 'success' && response.data) {
let itemList = response.data.items || [];
if (filters.search) {
itemList = itemList.filter((item: InventoryItem) =>
item.name.toLowerCase().includes(filters.search.toLowerCase()) ||
item.description?.toLowerCase().includes(filters.search.toLowerCase()) ||
item.sku?.toLowerCase().includes(filters.search.toLowerCase())
);
}
setItems(itemList);
setTotalPages(response.data.pagination?.total_pages || 1);
setTotalItems(response.data.pagination?.total || 0);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load inventory items');
} finally {
setLoading(false);
}
};
const fetchLowStockItems = async () => {
try {
const response = await inventoryService.getLowStockItems();
if (response.status === 'success' && response.data) {
setLowStockItems(response.data.items || []);
}
} catch (error) {
console.error('Error fetching low stock items:', error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
toast.error('Please enter an item name');
return;
}
try {
if (editingItem) {
await inventoryService.updateInventoryItem(editingItem.id, formData);
toast.success('Inventory item updated successfully');
} else {
await inventoryService.createInventoryItem(formData);
toast.success('Inventory item created successfully');
}
setShowModal(false);
resetForm();
fetchItems();
fetchLowStockItems();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to save inventory item');
}
};
const handleEdit = (item: InventoryItem) => {
setEditingItem(item);
setFormData({
name: item.name,
description: item.description || '',
category: item.category,
unit: item.unit,
minimum_quantity: item.minimum_quantity,
maximum_quantity: item.maximum_quantity || 0,
reorder_quantity: item.reorder_quantity || 0,
unit_cost: item.unit_cost || 0,
supplier: item.supplier || '',
supplier_contact: '',
storage_location: item.storage_location || '',
barcode: item.barcode || '',
sku: item.sku || '',
notes: '',
});
setShowModal(true);
};
const resetForm = () => {
setEditingItem(null);
setFormData({
name: '',
description: '',
category: 'cleaning_supplies',
unit: 'piece',
minimum_quantity: 0,
maximum_quantity: 0,
reorder_quantity: 0,
unit_cost: 0,
supplier: '',
supplier_contact: '',
storage_location: '',
barcode: '',
sku: '',
notes: '',
});
};
if (loading && items.length === 0) {
return <Loading fullScreen text="Loading inventory..." />;
}
const getLowStockCount = () => lowStockItems.length;
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-blue-400 to-blue-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Inventory View
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage inventory items and stock levels</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl font-semibold hover:from-blue-600 hover:to-blue-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="w-5 h-5" />
Add Item
</button>
</div>
{/* Filters */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-blue-500 transition-colors" />
<input
type="text"
placeholder="Search inventory..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.category}
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Categories</option>
{categories.map((category) => (
<option key={category.value} value={category.value}>
{category.label}
</option>
))}
</select>
<label className="flex items-center gap-2 px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl cursor-pointer hover:shadow-md transition-all">
<input
type="checkbox"
checked={filters.low_stock}
onChange={(e) => setFilters({ ...filters, low_stock: e.target.checked })}
className="w-5 h-5 text-blue-600 rounded"
/>
<span className="text-slate-700 font-medium">Low Stock Only</span>
</label>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Total Items</p>
<p className="text-3xl font-bold text-slate-900 mt-2">{totalItems}</p>
</div>
<Package className="w-12 h-12 text-blue-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Low Stock Alert</p>
<p className="text-3xl font-bold text-red-600 mt-2">{getLowStockCount()}</p>
</div>
<AlertTriangle className="w-12 h-12 text-red-500" />
</div>
</div>
</div>
{/* Items Table */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b-2 border-slate-200">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Item
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Category
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Current Stock
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Minimum
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Location
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{items.map((item) => {
const isLowStock = item.current_quantity !== null && item.minimum_quantity !== null && item.current_quantity <= item.minimum_quantity;
return (
<tr key={item.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div>
<div className="font-semibold text-slate-900">{item.name}</div>
{item.description && (
<div className="text-sm text-slate-500 mt-1 line-clamp-2">{item.description}</div>
)}
{item.sku && (
<div className="text-xs text-slate-400 mt-1">SKU: {item.sku}</div>
)}
</div>
</td>
<td className="px-6 py-4">
<span className="px-3 py-1 rounded-full text-sm font-medium bg-slate-100 text-slate-800">
{categories.find((c) => c.value === item.category)?.label || item.category}
</span>
</td>
<td className="px-6 py-4">
<div className="font-semibold text-slate-900">
{item.current_quantity !== null ? item.current_quantity : 'N/A'} {item.unit}
</div>
</td>
<td className="px-6 py-4">
<div className="text-slate-600">
{item.minimum_quantity !== null ? item.minimum_quantity : '—'} {item.unit}
</div>
</td>
<td className="px-6 py-4">
{isLowStock ? (
<span className="px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
Low Stock
</span>
) : (
<span className="px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
In Stock
</span>
)}
</td>
<td className="px-6 py-4 text-slate-600">
{item.storage_location || '—'}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(item)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => setViewingItem(item)}
className="p-2 text-gray-600 hover:bg-gray-50 rounded-lg transition-colors"
title="View Details"
>
<Eye className="w-5 h-5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{items.length === 0 && !loading && (
<div className="text-center py-12">
<Package className="w-16 h-16 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 text-lg">No inventory items found</p>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
{/* View Details Modal */}
{viewingItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">Item Details</h2>
<button
onClick={() => setViewingItem(null)}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<h3 className="text-xl font-bold text-slate-900 mb-2">{viewingItem.name}</h3>
{viewingItem.description && (
<p className="text-slate-600">{viewingItem.description}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-slate-600">Category</p>
<p className="font-semibold">{categories.find((c) => c.value === viewingItem.category)?.label || viewingItem.category}</p>
</div>
<div>
<p className="text-sm text-slate-600">Unit</p>
<p className="font-semibold">{viewingItem.unit}</p>
</div>
<div>
<p className="text-sm text-slate-600">Current Stock</p>
<p className="font-semibold text-xl">{viewingItem.current_quantity !== null ? viewingItem.current_quantity : 'N/A'} {viewingItem.unit}</p>
</div>
<div>
<p className="text-sm text-slate-600">Minimum Quantity</p>
<p className="font-semibold">{viewingItem.minimum_quantity !== null ? viewingItem.minimum_quantity : '—'} {viewingItem.unit}</p>
</div>
{viewingItem.storage_location && (
<div>
<p className="text-sm text-slate-600">Storage Location</p>
<p className="font-semibold">{viewingItem.storage_location}</p>
</div>
)}
{viewingItem.sku && (
<div>
<p className="text-sm text-slate-600">SKU</p>
<p className="font-semibold">{viewingItem.sku}</p>
</div>
)}
</div>
{viewingItem.supplier && (
<div>
<p className="text-sm text-slate-600 mb-2">Supplier</p>
<p className="font-semibold">{viewingItem.supplier}</p>
{viewingItem.supplier_contact && (
<p className="text-slate-600 text-sm mt-1">{viewingItem.supplier_contact}</p>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">
{editingItem ? 'Edit Inventory Item' : 'Add Inventory Item'}
</h2>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Item Name *</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Category *</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
required
>
{categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
rows={3}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Unit *</label>
<select
value={formData.unit}
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
required
>
{units.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Min Quantity *</label>
<input
type="number"
value={formData.minimum_quantity}
onChange={(e) => setFormData({ ...formData, minimum_quantity: parseFloat(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
min="0"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Storage Location</label>
<input
type="text"
value={formData.storage_location}
onChange={(e) => setFormData({ ...formData, storage_location: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Supplier</label>
<input
type="text"
value={formData.supplier}
onChange={(e) => setFormData({ ...formData, supplier: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">SKU</label>
<input
type="text"
value={formData.sku}
onChange={(e) => setFormData({ ...formData, sku: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
/>
</div>
</div>
<div className="flex gap-4 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="flex-1 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl font-semibold hover:from-blue-600 hover:to-blue-700 transition-all"
>
{editingItem ? 'Update Item' : 'Create Item'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default InventoryViewPage;

View File

@@ -0,0 +1,232 @@
import React, { useEffect, useState } from 'react';
import { Calendar, Clock, CheckCircle, AlertCircle } from 'lucide-react';
import staffShiftService, { StaffShift, StaffTask } from '../../features/staffShifts/services/staffShiftService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import { formatDate } from '../../shared/utils/format';
const StaffShiftViewPage: React.FC = () => {
const [shifts, setShifts] = useState<StaffShift[]>([]);
const [tasks, setTasks] = useState<StaffTask[]>([]);
const [loading, setLoading] = useState(true);
const [selectedDate, setSelectedDate] = useState(new Date());
useEffect(() => {
fetchShifts();
fetchTasks();
}, [selectedDate]);
const fetchShifts = async () => {
try {
setLoading(true);
const dateStr = selectedDate.toISOString().split('T')[0];
const response = await staffShiftService.getShifts({
shift_date: dateStr,
limit: 100,
});
if (response.status === 'success' && response.data) {
setShifts(response.data.shifts || []);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load shifts');
} finally {
setLoading(false);
}
};
const fetchTasks = async () => {
try {
const response = await staffShiftService.getTasks({
limit: 100,
});
if (response.status === 'success' && response.data) {
setTasks(response.data.tasks || []);
}
} catch (error) {
console.error('Error fetching tasks:', error);
}
};
const handleStatusUpdate = async (shiftId: number, newStatus: string) => {
try {
await staffShiftService.updateShift(shiftId, {
status: newStatus,
});
toast.success('Shift status updated successfully');
fetchShifts();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to update status');
}
};
if (loading && shifts.length === 0) {
return <Loading fullScreen text="Loading your shifts..." />;
}
const getUpcomingShifts = () => shifts.filter((s) => s.status === 'scheduled').length;
const getCompletedShifts = () => shifts.filter((s) => s.status === 'completed').length;
const getPendingTasks = () => tasks.filter((t) => ['pending', 'assigned'].includes(t.status)).length;
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-blue-400 to-blue-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
My Shifts
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">View and manage your shift schedule</p>
</div>
<input
type="date"
value={selectedDate.toISOString().split('T')[0]}
onChange={(e) => setSelectedDate(new Date(e.target.value))}
className="px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
/>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Upcoming Shifts</p>
<p className="text-3xl font-bold text-blue-600 mt-2">{getUpcomingShifts()}</p>
</div>
<Calendar className="w-12 h-12 text-blue-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Completed</p>
<p className="text-3xl font-bold text-green-600 mt-2">{getCompletedShifts()}</p>
</div>
<CheckCircle className="w-12 h-12 text-green-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Pending Tasks</p>
<p className="text-3xl font-bold text-orange-600 mt-2">{getPendingTasks()}</p>
</div>
<AlertCircle className="w-12 h-12 text-orange-500" />
</div>
</div>
</div>
{/* Shifts List */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="p-6">
<h2 className="text-xl font-bold text-slate-900 mb-4">My Shifts</h2>
{shifts.length === 0 ? (
<div className="text-center py-12">
<Calendar className="w-16 h-16 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 text-lg">No shifts scheduled</p>
</div>
) : (
<div className="space-y-4">
{shifts.map((shift) => (
<div
key={shift.id}
className="bg-gradient-to-r from-slate-50 to-white border border-slate-200 rounded-xl p-6 hover:shadow-lg transition-all"
>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold text-slate-900">
{formatDate(shift.shift_date)}
</h3>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
shift.status === 'completed' ? 'bg-green-100 text-green-800' :
shift.status === 'in_progress' ? 'bg-blue-100 text-blue-800' :
shift.status === 'scheduled' ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'
}`}>
{shift.status.replace('_', ' ').toUpperCase()}
</span>
</div>
<div className="flex items-center gap-4 text-slate-600">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>{shift.start_time} - {shift.end_time}</span>
</div>
{shift.department && (
<span className="px-2 py-1 bg-slate-100 rounded text-sm">{shift.department}</span>
)}
</div>
{shift.notes && (
<p className="text-slate-600 mt-2 text-sm">{shift.notes}</p>
)}
</div>
{shift.status === 'scheduled' && (
<select
value={shift.status}
onChange={(e) => handleStatusUpdate(shift.id, e.target.value)}
className="px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 cursor-pointer"
>
<option value="scheduled">Scheduled</option>
<option value="in_progress">Start Shift</option>
<option value="cancelled">Cancel</option>
</select>
)}
{shift.status === 'in_progress' && (
<button
onClick={() => handleStatusUpdate(shift.id, 'completed')}
className="px-4 py-2 bg-green-600 text-white rounded-xl hover:bg-green-700 transition-colors"
>
End Shift
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Tasks List */}
{tasks.length > 0 && (
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="p-6">
<h2 className="text-xl font-bold text-slate-900 mb-4">My Tasks</h2>
<div className="space-y-3">
{tasks.slice(0, 5).map((task) => (
<div
key={task.id}
className="bg-slate-50 border border-slate-200 rounded-lg p-4"
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-slate-900">{task.title}</h4>
{task.description && (
<p className="text-sm text-slate-600 mt-1">{task.description}</p>
)}
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
task.priority === 'urgent' ? 'bg-red-100 text-red-800' :
task.priority === 'high' ? 'bg-orange-100 text-orange-800' :
'bg-blue-100 text-blue-800'
}`}>
{task.priority.toUpperCase()}
</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default StaffShiftViewPage;

View File

@@ -29,12 +29,17 @@ import CookiePreferencesLink from './CookiePreferencesLink';
import ChatWidget from '../../features/notifications/components/ChatWidget';
import pageContentService, { type PageContent } from '../../features/content/services/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
import apiClient from '../services/apiClient';
const Footer: React.FC = () => {
const { settings } = useCompanySettings();
const [pageContent, setPageContent] = useState<PageContent | null>(null);
const [homePageContent, setHomePageContent] = useState<PageContent | null>(null);
const [enabledPages, setEnabledPages] = useState<Set<string>>(new Set());
const [apiError, setApiError] = useState(false);
const [newsletterEmail, setNewsletterEmail] = useState('');
const [newsletterSubmitting, setNewsletterSubmitting] = useState(false);
const [newsletterMessage, setNewsletterMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
const fetchPageContent = async () => {
@@ -53,6 +58,32 @@ const Footer: React.FC = () => {
}
};
const fetchHomePageContent = async () => {
try {
const response = await pageContentService.getHomeContent();
if (response.status === 'success' && response.data?.page_content) {
let content = response.data.page_content;
// Parse sections_enabled if it's a string
if (typeof content.sections_enabled === 'string') {
try {
content.sections_enabled = JSON.parse(content.sections_enabled);
} catch (e) {
content.sections_enabled = {};
}
} else if (typeof content.sections_enabled !== 'object' || content.sections_enabled === null) {
content.sections_enabled = content.sections_enabled || {};
}
// Normalize boolean values
if (content.newsletter_enabled !== undefined) {
content.newsletter_enabled = Boolean(content.newsletter_enabled);
}
setHomePageContent(content);
}
} catch (err: any) {
console.error('Error fetching home content for newsletter:', err);
}
};
const checkEnabledPages = async () => {
const enabled = new Set<string>();
const policyPages = [
@@ -84,6 +115,7 @@ const Footer: React.FC = () => {
};
fetchPageContent();
fetchHomePageContent();
checkEnabledPages();
}, []);
@@ -174,9 +206,9 @@ const Footer: React.FC = () => {
<div className="relative container mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20 lg:py-24">
{/* Main Content Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-10 sm:gap-12 lg:gap-16 mb-16 sm:mb-20">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-8 sm:gap-10 lg:gap-8 mb-16 sm:mb-20">
{/* Brand Section */}
<div className="lg:col-span-2">
<div className="lg:col-span-4">
<div className="flex items-center space-x-4 mb-6 sm:mb-8">
{logoUrl ? (
<div className="relative group">
@@ -294,8 +326,8 @@ const Footer: React.FC = () => {
{/* Quick Links */}
{quickLinks.length > 0 && (
<div>
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-6 sm:mb-8 relative inline-block tracking-wide">
<div className="lg:col-span-2">
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-4 sm:mb-6 relative inline-block tracking-wide">
<span className="relative z-10">Quick Links</span>
<span className="absolute bottom-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#d4af37] via-[#d4af37]/50 to-transparent"></span>
</h3>
@@ -317,8 +349,8 @@ const Footer: React.FC = () => {
{/* Guest Services */}
{supportLinks.length > 0 && (
<div>
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-6 sm:mb-8 relative inline-block tracking-wide">
<div className="lg:col-span-2">
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-4 sm:mb-6 relative inline-block tracking-wide">
<span className="relative z-10">Guest Services</span>
<span className="absolute bottom-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#d4af37] via-[#d4af37]/50 to-transparent"></span>
</h3>
@@ -338,9 +370,78 @@ const Footer: React.FC = () => {
</div>
)}
{/* Contact Information */}
<div>
{/* Newsletter Subscription - Always Enabled */}
<div className="lg:col-span-2">
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-6 sm:mb-8 relative inline-block tracking-wide">
<span className="relative z-10">{homePageContent?.newsletter_section_title || 'Newsletter'}</span>
<span className="absolute bottom-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#d4af37] via-[#d4af37]/50 to-transparent"></span>
</h3>
{homePageContent?.newsletter_section_subtitle && (
<p className="text-sm text-gray-400 mb-4 font-light leading-relaxed">
{homePageContent.newsletter_section_subtitle}
</p>
)}
<form
className="space-y-3"
onSubmit={async (e) => {
e.preventDefault();
if (!newsletterEmail || newsletterSubmitting) return;
setNewsletterSubmitting(true);
setNewsletterMessage(null);
try {
const response = await apiClient.post('/email-campaigns/newsletter/subscribe', {
email: newsletterEmail
});
if (response.data?.status === 'success') {
setNewsletterMessage({ type: 'success', text: 'Successfully subscribed!' });
setNewsletterEmail('');
setTimeout(() => setNewsletterMessage(null), 5000);
} else {
setNewsletterMessage({ type: 'error', text: 'Failed to subscribe. Please try again.' });
}
} catch (error: any) {
console.error('Newsletter subscription error:', error);
const errorMessage = error.response?.data?.detail || error.message || 'Failed to subscribe. Please try again.';
setNewsletterMessage({ type: 'error', text: errorMessage });
} finally {
setNewsletterSubmitting(false);
}
}}
>
<input
type="email"
value={newsletterEmail}
onChange={(e) => setNewsletterEmail(e.target.value)}
placeholder={homePageContent?.newsletter_placeholder || 'Enter your email'}
className="w-full px-4 py-2.5 rounded-lg border border-gray-700 bg-gray-800/50 text-white placeholder-gray-400 focus:border-[#d4af37] focus:ring-2 focus:ring-[#d4af37]/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed text-sm"
required
disabled={newsletterSubmitting}
/>
<button
type="submit"
disabled={newsletterSubmitting || !newsletterEmail}
className="w-full px-4 py-2.5 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg font-semibold hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 shadow-lg shadow-[#d4af37]/30 hover:shadow-xl hover:shadow-[#d4af37]/40 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{newsletterSubmitting ? 'Subscribing...' : (homePageContent?.newsletter_button_text || 'Subscribe')}
</button>
{newsletterMessage && (
<div className={`text-xs px-3 py-2 rounded-lg ${
newsletterMessage.type === 'success'
? 'bg-green-500/20 text-green-300 border border-green-500/30'
: 'bg-red-500/20 text-red-300 border border-red-500/30'
}`}>
{newsletterMessage.text}
</div>
)}
</form>
</div>
{/* Contact Information */}
<div className="lg:col-span-2">
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-4 sm:mb-6 relative inline-block tracking-wide">
<span className="relative z-10">Contact</span>
<span className="absolute bottom-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#d4af37] via-[#d4af37]/50 to-transparent"></span>
</h3>

View File

@@ -36,7 +36,9 @@ import {
Webhook,
Key,
HardDrive,
Activity
Activity,
Calendar,
Boxes
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useResponsive } from '../../hooks';
@@ -136,6 +138,16 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
icon: Hotel,
label: 'Room Management'
},
{
path: '/admin/inventory',
icon: Boxes,
label: 'Inventory'
},
{
path: '/admin/shifts',
icon: Calendar,
label: 'Staff Shifts'
},
]
},
{

View File

@@ -18,7 +18,9 @@ import {
Bell,
Mail,
AlertTriangle,
TrendingUp
TrendingUp,
Package,
Calendar
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useChatNotifications } from '../../features/notifications/contexts/ChatNotificationContext';
@@ -106,11 +108,6 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
icon: CreditCard,
label: 'Payments'
},
{
path: '/staff/loyalty',
icon: Award,
label: 'Loyalty Program'
},
{
path: '/staff/guest-profiles',
icon: Users,
@@ -141,6 +138,16 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
icon: Wrench,
label: 'Room Management'
},
{
path: '/staff/inventory',
icon: Package,
label: 'Inventory'
},
{
path: '/staff/shifts',
icon: Calendar,
label: 'My Shifts'
},
{
path: '/staff/chats',
icon: MessageCircle,

View File

@@ -43,12 +43,25 @@ export const CookieConsentProvider: React.FC<{ children: React.ReactNode }> = ({
? window.localStorage.getItem('cookieConsentDecided')
: null;
const decided =
(localFlag === 'true') || Boolean((data as any).has_decided);
(localFlag === 'true') || Boolean(data?.has_decided);
setHasDecided(decided);
} catch (error) {
} catch (error: any) {
// If API fails, check localStorage for previous decision
const localFlag =
typeof window !== 'undefined'
? window.localStorage.getItem('cookieConsentDecided')
: null;
if (localFlag === 'true') {
// User has previously decided, don't show banner
setHasDecided(true);
} else {
// No previous decision, show banner (consent will be null)
setHasDecided(false);
}
if (import.meta.env.DEV) {
console.error('Failed to load cookie consent', error);
console.error('Failed to load cookie consent:', error?.message || error);
}
} finally {
if (isMounted) {