update
This commit is contained in:
@@ -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>
|
||||
|
||||
{}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
651
Frontend/src/pages/admin/InspectionManagementPage.tsx
Normal file
651
Frontend/src/pages/admin/InspectionManagementPage.tsx
Normal 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;
|
||||
|
||||
808
Frontend/src/pages/admin/InventoryManagementPage.tsx
Normal file
808
Frontend/src/pages/admin/InventoryManagementPage.tsx
Normal 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;
|
||||
|
||||
793
Frontend/src/pages/admin/MaintenanceManagementPage.tsx
Normal file
793
Frontend/src/pages/admin/MaintenanceManagementPage.tsx
Normal 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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
620
Frontend/src/pages/admin/StaffShiftManagementPage.tsx
Normal file
620
Frontend/src/pages/admin/StaffShiftManagementPage.tsx
Normal 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;
|
||||
|
||||
232
Frontend/src/pages/housekeeping/ShiftViewPage.tsx
Normal file
232
Frontend/src/pages/housekeeping/ShiftViewPage.tsx
Normal 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
601
Frontend/src/pages/staff/InventoryViewPage.tsx
Normal file
601
Frontend/src/pages/staff/InventoryViewPage.tsx
Normal 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;
|
||||
|
||||
232
Frontend/src/pages/staff/ShiftViewPage.tsx
Normal file
232
Frontend/src/pages/staff/ShiftViewPage.tsx
Normal 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user