From 37e3a16b196e3dc570c369665cad90a29aa4bff7 Mon Sep 17 00:00:00 2001 From: roje1030 Date: Thu, 4 Jun 2026 11:11:31 +0800 Subject: [PATCH 1/5] CSR UI: help drawers, unified KPI cards, instant tooltips, inventory low-stock warning --- Components/GlobalTooltip.tsx | 105 ++++ Components/Providers.tsx | 2 + Components/admin/Csr/BookingPage.tsx | 10 +- Components/admin/Csr/CleanersPage.tsx | 277 +++++---- Components/admin/Csr/CsrDashboardPage.tsx | 63 +- Components/admin/Csr/DashboardPage.tsx | 8 +- Components/admin/Csr/DeliverablesPage.tsx | 442 +++++++------- Components/admin/Csr/DepositPage.tsx | 74 +-- Components/admin/Csr/DiscountPage.tsx | 527 +++++++++-------- Components/admin/Csr/InventoryPage.tsx | 543 ++++++++++-------- .../admin/Csr/Modals/ViewPaymentModal.tsx | 58 +- Components/admin/Csr/PaymentPage.tsx | 134 +++-- Components/admin/Csr/PaymentsHub.tsx | 188 ++++++ .../admin/Owners/PartnerManagementPage.tsx | 2 +- package-lock.json | 28 + 15 files changed, 1468 insertions(+), 993 deletions(-) create mode 100644 Components/GlobalTooltip.tsx create mode 100644 Components/admin/Csr/PaymentsHub.tsx diff --git a/Components/GlobalTooltip.tsx b/Components/GlobalTooltip.tsx new file mode 100644 index 00000000..dfb2b7ef --- /dev/null +++ b/Components/GlobalTooltip.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface TipState { + text: string; + left: number; + top: number; + placement: "top" | "bottom"; +} + +/** + * GlobalTooltip + * Replaces the slow, unreliable native `title` tooltip (which only appears after a + * ~1s hover and resets when the mouse moves) with an instant custom tooltip. + * + * It works app-wide via event delegation: on hover, it moves the element's `title` + * into `data-tooltip` (suppressing the browser's built-in tooltip) and renders a + * styled tooltip immediately. No per-button changes are required — every element + * that already has a `title` attribute is covered automatically. + */ +export default function GlobalTooltip() { + const [tip, setTip] = useState(null); + + useEffect(() => { + let activeEl: HTMLElement | null = null; + + const show = (el: HTMLElement) => { + // Move native title into data-tooltip so the browser's own tooltip never shows + const native = el.getAttribute("title"); + if (native && native.trim()) { + el.setAttribute("data-tooltip", native); + el.removeAttribute("title"); + } + + const text = el.getAttribute("data-tooltip"); + if (!text || !text.trim()) return; + + activeEl = el; + const rect = el.getBoundingClientRect(); + const placement: "top" | "bottom" = rect.top < 44 ? "bottom" : "top"; + setTip({ + text, + left: rect.left + rect.width / 2, + top: placement === "top" ? rect.top : rect.bottom, + placement, + }); + }; + + const hide = () => { + activeEl = null; + setTip(null); + }; + + const onOver = (e: MouseEvent) => { + const target = e.target as HTMLElement | null; + if (!target || typeof target.closest !== "function") return; + const el = target.closest("[title], [data-tooltip]") as HTMLElement | null; + if (!el || el === activeEl) return; + show(el); + }; + + const onOut = (e: MouseEvent) => { + if (!activeEl) return; + // Ignore movement to a child of the active element (prevents flicker) + const related = e.relatedTarget as Node | null; + if (related && activeEl.contains(related)) return; + hide(); + }; + + document.addEventListener("mouseover", onOver, true); + document.addEventListener("mouseout", onOut, true); + // Hide on scroll/click so the tooltip never lingers in the wrong place + window.addEventListener("scroll", hide, true); + window.addEventListener("click", hide, true); + + return () => { + document.removeEventListener("mouseover", onOver, true); + document.removeEventListener("mouseout", onOut, true); + window.removeEventListener("scroll", hide, true); + window.removeEventListener("click", hide, true); + }; + }, []); + + if (!tip) return null; + + return ( +
+ {tip.text} +
+ ); +} diff --git a/Components/Providers.tsx b/Components/Providers.tsx index f14ccfc9..691a2bc8 100644 --- a/Components/Providers.tsx +++ b/Components/Providers.tsx @@ -19,6 +19,7 @@ import { ThemeProvider as NextThemesProvider } from "next-themes"; import { Toaster } from "react-hot-toast"; import { useInactivityLogout } from '@/hooks/useInactivityLogout'; import ConditionalLayout from './ConditionalLayout'; +import GlobalTooltip from './GlobalTooltip'; function InactivityLogoutWrapper({ children }: { children: React.ReactNode }) { useInactivityLogout(); @@ -40,6 +41,7 @@ export function Providers ({ children }: {children:React.ReactNode}) { {children} + -
+
-

{stat.label}

+

{stat.label}

{stat.value}

- +
); @@ -1116,7 +1116,7 @@ export default function BookingsPage() { {/* Bookings Table - Desktop View */} -
+
diff --git a/Components/admin/Csr/CleanersPage.tsx b/Components/admin/Csr/CleanersPage.tsx index 087eaaa0..032151a5 100644 --- a/Components/admin/Csr/CleanersPage.tsx +++ b/Components/admin/Csr/CleanersPage.tsx @@ -21,6 +21,8 @@ import { PlayCircle, RefreshCw, Calendar, + HelpCircle, + X, } from "lucide-react"; import { useMemo, useState, useEffect } from "react"; import { useSession } from "next-auth/react"; @@ -257,8 +259,8 @@ export default function CleanersPage() { const [isViewModalOpen, setIsViewModalOpen] = useState(false); const [assignmentBookingId, setAssignmentBookingId] = useState(null); const [isAssignModalOpen, setIsAssignModalOpen] = useState(false); - const [showStatusGuide, setShowStatusGuide] = useState(false); - const [showCleaningGuide, setShowCleaningGuide] = useState(false); + const [showGuideDrawer, setShowGuideDrawer] = useState(false); + const [activeGuideTab, setActiveGuideTab] = useState<"status" | "manage">("status"); const [guideLanguage, setGuideLanguage] = useState<"en" | "fil">("en"); const [now, setNow] = useState(() => Date.now()); @@ -519,129 +521,19 @@ export default function CleanersPage() { <>
{/* Header */} -
+

Cleaners Management

Assign and track post check-out cleaning tasks

-
- - {/* Status Guide */} -
-
- -
- {(['en', 'fil'] as const).map((lang) => ( - - ))} -
-
- - {showStatusGuide && ( -
- {guideTranslations[guideLanguage].statusGuide.statuses.map((status, idx) => { - const statusColors: Record = { - Unassigned: { dot: 'bg-gray-500' }, - Assigned: { dot: 'bg-indigo-500' }, - "In Progress": { dot: 'bg-yellow-500' }, - Completed: { dot: 'bg-green-500' } - }; - const color = statusColors[status.name] || { dot: 'bg-gray-500' }; - - return ( -
-
-
-
{status.name}
-

{status.description}

-
-
- ); - })} -
- )} -
- - {/* How to Manage Cleaning Tasks Guide */} -
-
- -
- {(['en', 'fil'] as const).map((lang) => ( - - ))} -
-
- - {showCleaningGuide && ( -
-
- {guideTranslations[guideLanguage].cleaningGuide.steps.map((step, idx) => ( -
-
{idx + 1}
-
-
{step.title}
-

{step.description}

-
-
- ))} -
- -
-
{guideTranslations[guideLanguage].cleaningGuide.workflowTitle}
-
- {guideTranslations[guideLanguage].cleaningGuide.workflows.map((workflow, idx) => { - const getWorkflowIcon = (title: string) => { - if (title.includes("Unassigned")) return { Icon: Users, color: 'text-gray-600 dark:text-gray-400' }; - if (title.includes("Assigned")) return { Icon: ClipboardList, color: 'text-indigo-600 dark:text-indigo-400' }; - if (title.includes("In Progress")) return { Icon: PlayCircle, color: 'text-yellow-600 dark:text-yellow-400' }; - return { Icon: CheckCircle, color: 'text-green-600 dark:text-green-400' }; - }; - const iconData = getWorkflowIcon(workflow.title); - - return ( -
- - {workflow.title}: {workflow.description} -
- ); - })} -
-
-
- )} +
{/* Summary Cards */} @@ -657,11 +549,11 @@ export default function CleanersPage() { return (
-
+
-

{stat.label}

+

{stat.label}

{isLoading ? (
@@ -670,7 +562,7 @@ export default function CleanersPage() { )}
- +
); @@ -1275,6 +1167,141 @@ export default function CleanersPage() {
+ {/* Help & Guides Drawer */} + {showGuideDrawer && ( +
+ {/* Backdrop */} +
setShowGuideDrawer(false)} + /> + + {/* Panel */} +
+ {/* Drawer Header */} +
+
+ +

Help & Guides

+
+
+
+ {(['en', 'fil'] as const).map((lang) => ( + + ))} +
+ +
+
+ + {/* Tabs */} +
+ {([ + { key: 'status', label: 'Status' }, + { key: 'manage', label: 'Manage' }, + ] as const).map((tab) => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeGuideTab === 'status' && ( +
+

{guideTranslations[guideLanguage].statusGuide.title}

+
+ {guideTranslations[guideLanguage].statusGuide.statuses.map((status, idx) => { + const statusColors: Record = { + Unassigned: { dot: 'bg-gray-500' }, + Assigned: { dot: 'bg-indigo-500' }, + "In Progress": { dot: 'bg-yellow-500' }, + Completed: { dot: 'bg-green-500' } + }; + const color = statusColors[status.name] || { dot: 'bg-gray-500' }; + + return ( +
+
+
+
{status.name}
+

{status.description}

+
+
+ ); + })} +
+
+ )} + + {activeGuideTab === 'manage' && ( +
+

{guideTranslations[guideLanguage].cleaningGuide.title}

+
+ {guideTranslations[guideLanguage].cleaningGuide.steps.map((step, idx) => ( +
+
{idx + 1}
+
+
{step.title}
+

{step.description}

+
+
+ ))} +
+ +
+
{guideTranslations[guideLanguage].cleaningGuide.workflowTitle}
+
+ {guideTranslations[guideLanguage].cleaningGuide.workflows.map((workflow, idx) => { + const getWorkflowIcon = (title: string) => { + if (title.includes("Unassigned")) return { Icon: Users, color: 'text-gray-600 dark:text-gray-400' }; + if (title.includes("Assigned")) return { Icon: ClipboardList, color: 'text-indigo-600 dark:text-indigo-400' }; + if (title.includes("In Progress")) return { Icon: PlayCircle, color: 'text-yellow-600 dark:text-yellow-400' }; + return { Icon: CheckCircle, color: 'text-green-600 dark:text-green-400' }; + }; + const iconData = getWorkflowIcon(workflow.title); + + return ( +
+ + {workflow.title}: {workflow.description} +
+ ); + })} +
+
+
+ )} +
+
+
+ )} + {isViewModalOpen && selectedBooking && ( )} diff --git a/Components/admin/Csr/CsrDashboardPage.tsx b/Components/admin/Csr/CsrDashboardPage.tsx index 432bc998..669ec5fd 100644 --- a/Components/admin/Csr/CsrDashboardPage.tsx +++ b/Components/admin/Csr/CsrDashboardPage.tsx @@ -1,16 +1,15 @@ "use client"; -import { Menu, X, Home, Calendar, CalendarDays, DollarSign, FileText, Users, Wallet, Package, Settings, Bell, ChevronDown, User, MessageSquare, BarChart3, Headphones, Moon, Sun, Monitor, Cloud, CloudRain, CloudSnow, Activity, Tag, Globe } from "lucide-react"; +import { Menu, X, Home, Calendar, CalendarDays, DollarSign, FileText, Users, Package, Settings, Bell, ChevronDown, User, MessageSquare, BarChart3, Headphones, Moon, Sun, Monitor, Cloud, CloudRain, CloudSnow, Activity, Tag, Globe } from "lucide-react"; import Image from "next/image"; import { useMemo, useState, useEffect, useRef } from "react"; import { useSession, signOut } from "next-auth/react"; import { useTheme } from "next-themes"; import DashboardPage from "./DashboardPage"; import BookingsPage from "./BookingPage"; -import PaymentsPage from "./PaymentPage.tsx"; +import PaymentsHub from "./PaymentsHub"; import DeliverablesPage from "./DeliverablesPage"; import CleanersPage from "./CleanersPage"; -import DepositsPage from "./DepositPage"; import DiscountPage from "./DiscountPage"; import SettingsPage from "./SettingsPage"; import MessagePage from "./MessagePage"; @@ -394,14 +393,14 @@ export default function CsrDashboard() { { id: "bookings", icon: Calendar, - label: "Guest Bookings", + label: "Bookings", subtitle: "Management", color: "text-green-500", }, { id: "calendar", icon: CalendarDays, - label: "Booking Calendar", + label: "Calendar", color: "text-cyan-500", }, { @@ -418,21 +417,14 @@ export default function CsrDashboard() { { id: "payments", icon: DollarSign, - label: "Guest Down Payment", - subtitle: "Management", + label: "Payments", + subtitle: "Down payment & deposit", color: "text-purple-500", }, - { - id: "deposits", - icon: Wallet, - label: "Guest Security Deposit", - subtitle: "Management", - color: "text-indigo-500", - }, { id: "discounts", icon: Tag, - label: "Discount Management", + label: "Discount", color: "text-yellow-500", }, ], @@ -443,43 +435,25 @@ export default function CsrDashboard() { { id: "deliverables", icon: FileText, - label: "Guest Deliverables", + label: "Add-ons", subtitle: "Assign deliverables", color: "text-pink-500", }, { id: "cleaners", icon: Users, - label: "Cleaners Management", + label: "Cleaning", subtitle: "Assign Cleaners", color: "text-brand-primary", }, { id: "inventory", icon: Package, - label: "Inventory Management", + label: "Inventory", color: "text-teal-500", }, ], }, - { - category: "Communication", - items: [ - { - id: "messages", - icon: MessageSquare, - label: "Messages", - color: "text-green-500", - }, - ], - }, - { - category: "System", - items: [ - { id: "activity-logs", icon: Activity, label: "Activity Logs", color: "text-orange-500" }, - { id: "settings", icon: Settings, label: "Settings", color: "text-gray-500" }, - ], - }, ]; const handleLogout = async () => { @@ -833,6 +807,20 @@ export default function CsrDashboard() { My Profile + -
- {(['en', 'fil'] as const).map((lang) => ( - - ))} -
-
- - {showStatusGuide && ( -
- {guideTranslations[guideLanguage].statusGuide.statuses.map((status, idx) => { - const statusColors: Record = { - Pending: 'bg-yellow-500', - Preparing: 'bg-indigo-500', - Delivered: 'bg-green-500', - Cancelled: 'bg-red-500', - Refunded: 'bg-orange-500' - }; - const color = statusColors[status.name] || 'bg-gray-500'; - - return ( -
-
-
-
{status.name}
-

{status.description}

-
-
- ); - })} -
- )} -
- - {/* How to Use Deliverables Guide */} -
-
- -
- {(['en', 'fil'] as const).map((lang) => ( - - ))} -
-
- - {showUsageGuide && ( -
-
- {guideTranslations[guideLanguage].usageGuide.steps.map((step, idx) => ( -
-
{idx + 1}
-
-
{step.title}
-

{step.description}

-
-
- ))} -
- -
-
{guideTranslations[guideLanguage].usageGuide.actionGuideTitle}
-
- {guideTranslations[guideLanguage].usageGuide.actions.map((action, idx) => { - const getActionIcon = (title: string) => { - const iconMap: Record = { - Preparing: Play, - Delivered: Truck, - Cancelled: XCircle - }; - return iconMap[title] || Play; - }; - - const getActionColor = (title: string) => { - const colorMap: Record = { - Preparing: 'text-indigo-600 dark:text-indigo-400', - Delivered: 'text-green-600 dark:text-green-400', - Cancelled: 'text-red-600 dark:text-red-400' - }; - return colorMap[title] || 'text-gray-600 dark:text-gray-400'; - }; - - const IconComponent = getActionIcon(action.title); - const iconColor = getActionColor(action.title); - - return ( -
- - {action.title}: {action.description} -
- ); - })} -
-
-
- )} -
- - {/* Bulk Operations Guide */} -
-
- -
- {(['en', 'fil'] as const).map((lang) => ( - - ))} -
-
- - {showBulkGuide && ( -
-
- {guideTranslations[guideLanguage].bulkGuide.steps.map((step, idx) => ( -
-
{idx + 1}
-
-
{step.title}
-

{step.description}

-
-
- ))} -
- -
-
{guideTranslations[guideLanguage].bulkGuide.whenToUseTitle}
-
- {guideTranslations[guideLanguage].bulkGuide.useCases.map((useCase, idx) => { - const getUseCaseIcon = (title: string) => { - const iconMap: Record = { - 'Mark as Preparing': Play, - 'Mark as Delivered': Truck, - 'Mark as Cancelled': XCircle - }; - return iconMap[title] || Play; - }; - - const getUseCaseColor = (title: string) => { - const colorMap: Record = { - 'Mark as Preparing': 'text-indigo-600 dark:text-indigo-400', - 'Mark as Delivered': 'text-green-600 dark:text-green-400', - 'Mark as Cancelled': 'text-red-600 dark:text-red-400' - }; - return colorMap[title] || 'text-gray-600 dark:text-gray-400'; - }; - - const IconComponent = getUseCaseIcon(useCase.title); - const iconColor = getUseCaseColor(useCase.title); - - return ( -
- - {useCase.title}: {useCase.description} -
- ); - })} -
-
-
- )} +
{/* Summary Cards */} @@ -1180,11 +978,11 @@ const handleMarkAllDelivered = async (bookingId: string) => { return (
-
+
-

{stat.label}

+

{stat.label}

{isLoading ? (
@@ -1193,7 +991,7 @@ const handleMarkAllDelivered = async (bookingId: string) => { )}
- +
); @@ -2101,6 +1899,208 @@ const handleMarkAllDelivered = async (bookingId: string) => {
+ {/* Help & Guides Drawer */} + {showGuideDrawer && ( +
+ {/* Backdrop */} +
setShowGuideDrawer(false)} + /> + + {/* Panel */} +
+ {/* Drawer Header */} +
+
+ +

Help & Guides

+
+
+
+ {(['en', 'fil'] as const).map((lang) => ( + + ))} +
+ +
+
+ + {/* Tabs */} +
+ {([ + { key: 'status', label: 'Status' }, + { key: 'manage', label: 'Manage' }, + { key: 'bulk', label: 'Bulk' }, + ] as const).map((tab) => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeGuideTab === 'status' && ( +
+

{guideTranslations[guideLanguage].statusGuide.title}

+
+ {guideTranslations[guideLanguage].statusGuide.statuses.map((status, idx) => { + const statusColors: Record = { + Pending: 'bg-yellow-500', + Preparing: 'bg-indigo-500', + Delivered: 'bg-green-500', + Cancelled: 'bg-red-500', + Refunded: 'bg-orange-500' + }; + const color = statusColors[status.name] || 'bg-gray-500'; + + return ( +
+
+
+
{status.name}
+

{status.description}

+
+
+ ); + })} +
+
+ )} + + {activeGuideTab === 'manage' && ( +
+

{guideTranslations[guideLanguage].usageGuide.title}

+
+ {guideTranslations[guideLanguage].usageGuide.steps.map((step, idx) => ( +
+
{idx + 1}
+
+
{step.title}
+

{step.description}

+
+
+ ))} +
+ +
+
{guideTranslations[guideLanguage].usageGuide.actionGuideTitle}
+
+ {guideTranslations[guideLanguage].usageGuide.actions.map((action, idx) => { + const getActionIcon = (title: string) => { + const iconMap: Record = { + Preparing: Play, + Delivered: Truck, + Cancelled: XCircle + }; + return iconMap[title] || Play; + }; + + const getActionColor = (title: string) => { + const colorMap: Record = { + Preparing: 'text-indigo-600 dark:text-indigo-400', + Delivered: 'text-green-600 dark:text-green-400', + Cancelled: 'text-red-600 dark:text-red-400' + }; + return colorMap[title] || 'text-gray-600 dark:text-gray-400'; + }; + + const IconComponent = getActionIcon(action.title); + const iconColor = getActionColor(action.title); + + return ( +
+ + {action.title}: {action.description} +
+ ); + })} +
+
+
+ )} + + {activeGuideTab === 'bulk' && ( +
+

{guideTranslations[guideLanguage].bulkGuide.title}

+
+ {guideTranslations[guideLanguage].bulkGuide.steps.map((step, idx) => ( +
+
{idx + 1}
+
+
{step.title}
+

{step.description}

+
+
+ ))} +
+ +
+
{guideTranslations[guideLanguage].bulkGuide.whenToUseTitle}
+
+ {guideTranslations[guideLanguage].bulkGuide.useCases.map((useCase, idx) => { + const getUseCaseIcon = (title: string) => { + const iconMap: Record = { + 'Mark as Preparing': Play, + 'Mark as Delivered': Truck, + 'Mark as Cancelled': XCircle + }; + return iconMap[title] || Play; + }; + + const getUseCaseColor = (title: string) => { + const colorMap: Record = { + 'Mark as Preparing': 'text-indigo-600 dark:text-indigo-400', + 'Mark as Delivered': 'text-green-600 dark:text-green-400', + 'Mark as Cancelled': 'text-red-600 dark:text-red-400' + }; + return colorMap[title] || 'text-gray-600 dark:text-gray-400'; + }; + + const IconComponent = getUseCaseIcon(useCase.title); + const iconColor = getUseCaseColor(useCase.title); + + return ( +
+ + {useCase.title}: {useCase.description} +
+ ); + })} +
+
+
+ )} +
+
+
+ )} + {/* Payment Details Modal */}
-

Deposit Management

+

Security Deposit

Manage security deposits for bookings

@@ -1140,6 +1140,7 @@ export default function DepositPage() {
{/* Summary Cards */} + {!hideSummary && (
{[ { label: "Total Bookings", value: String(totalCount), color: "bg-gray-500", icon: Wallet }, @@ -1151,11 +1152,11 @@ export default function DepositPage() { return (
-
+
-

{stat.label}

+

{stat.label}

{isLoading ? (
@@ -1164,12 +1165,13 @@ export default function DepositPage() { )}
- +
); })}
+ )} {/* Bulk Actions Bar */} {selectedDeposits.length > 0 && ( @@ -1470,7 +1472,11 @@ export default function DepositPage() {
) : ( paginatedRows.map((row, index) => ( -
+
@@ -1619,7 +1625,7 @@ export default function DepositPage() {
- - + @@ -1698,18 +1704,18 @@ export default function DepositPage() { className="border border-gray-200 dark:border-gray-700 animate-pulse" > {/* Select Checkbox */} - {/* Deposit ID */} - {/* Haven & Booking */} - {/* Guest */} - {/* Amount */} - {/* Status */} - {/* Check-in / Check-out */} - {/* Actions */} - ) : ( paginatedRows.map((row, index) => ( - - + - - - - - - - {/* Select */} - {/* Booking ID */} - {/* Guest Details */} - {/* Check-in / Check-out */} - {/* Total */} - {/* Payment Proof */} - {/* Actions */} - + + + `; + + const outRows = outItems.map((i) => row(i.name, "Out of stock", "#dc2626")).join(""); + const lowRows = lowItems.map((i) => row(i.name, `${i.stock} pcs left`, "#d97706")).join(""); + + return ` + + + + +
+
+
🏡 Staycation Haven
+
Inventory Alert
+
+
+
+
⚠️ ${total} item(s) need restocking
+
Items at or below ${threshold} pcs as of ${date}.
+
+
+
handleSort("deposit_id")} - className="text-left py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" + className="text-left py-2.5 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" >
Deposit ID @@ -1643,7 +1649,7 @@ export default function DepositPage() {
handleSort("haven")} - className="text-left py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" + className="text-left py-2.5 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" >
Haven & Booking @@ -1652,7 +1658,7 @@ export default function DepositPage() {
handleSort("guest")} - className="text-left py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" + className="text-left py-2.5 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" >
Guest @@ -1661,7 +1667,7 @@ export default function DepositPage() {
handleSort("deposit_amount")} - className="text-right py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" + className="text-right py-2.5 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" >
Amount @@ -1670,7 +1676,7 @@ export default function DepositPage() {
handleSort("status")} - className="text-center py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors whitespace-nowrap" + className="text-center py-2.5 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors whitespace-nowrap" >
Status @@ -1679,14 +1685,14 @@ export default function DepositPage() {
handleSort("checkin_date")} - className="text-left py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" + className="text-left py-2.5 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" >
Check-in / Check-out Dates
ActionsActions
+
+
+
@@ -1717,7 +1723,7 @@ export default function DepositPage() {
+
@@ -1725,22 +1731,22 @@ export default function DepositPage() {
+
+
+
+
@@ -1758,8 +1764,12 @@ export default function DepositPage() {
+
+
{highlightText(row.deposit_id, searchTerm)} {row.payment_method ? ( @@ -1799,7 +1809,7 @@ export default function DepositPage() { )}
+
@@ -1815,7 +1825,7 @@ export default function DepositPage() {
+
@@ -1865,7 +1875,7 @@ export default function DepositPage() {
+
{highlightText(row.formatted_amount, searchTerm)} @@ -1894,7 +1904,7 @@ export default function DepositPage() { )}
+ +
@@ -1926,7 +1936,7 @@ export default function DepositPage() {
+
-
- {(['en', 'fil'] as const).map((lang) => ( - - ))} -
-
- - {showStatusGuide && ( -
- {guideTranslations[guideLanguage].statusGuide.statuses.map((status, idx) => { - const statusColors: Record = { - Active: { dot: 'bg-green-500' }, - Inactive: { dot: 'bg-red-500' } - }; - const color = statusColors[status.name] || { dot: 'bg-gray-500' }; - - return ( -
-
-
-
{status.name}
-

{status.description}

-
-
- ); - })} -
- )} - - - {/* How to Manage Discounts Guide */} -
-
- -
- {(['en', 'fil'] as const).map((lang) => ( - - ))} -
-
- - {showDiscountGuide && ( -
-
- {guideTranslations[guideLanguage].discountGuide.steps.map((step, idx) => ( -
-
{idx + 1}
-
-
{step.title}
-

{step.description}

-
-
- ))} -
- -
-
{guideTranslations[guideLanguage].discountGuide.actionGuideTitle}
-
- {guideTranslations[guideLanguage].discountGuide.actions.map((action, idx) => { - const getActionIconAndColor = (title: string) => { - const iconMap: Record = { - Active: { Icon: CheckCircle, color: 'text-green-600 dark:text-green-400' }, - Inactive: { Icon: XCircle, color: 'text-red-600 dark:text-red-400' } - }; - return iconMap[title]; - }; - const iconData = getActionIconAndColor(action.title); - - return ( -
- {iconData && } - {action.title}: {action.description} -
- ); - })} -
-
-
- )} -
- - {/* Bulk Operations Guide */} -
-
- -
- {(['en', 'fil'] as const).map((lang) => ( - - ))} -
-
- - {showBulkGuide && ( -
-
- {guideTranslations[guideLanguage].bulkGuide.steps.map((step, idx) => ( -
-
{idx + 1}
-
-
{step.title}
-

{step.description}

-
-
- ))} -
- -
-
{guideTranslations[guideLanguage].bulkGuide.whenToUseTitle}
-
- {guideTranslations[guideLanguage].bulkGuide.useCases.map((useCase, idx) => { - const getUseCaseIcon = (title: string) => { - const iconMap: Record = { - 'Activate Multiple': CheckCircle, - 'Deactivate Multiple': XCircle, - 'Delete Expired': Trash2 - }; - return iconMap[title] || CheckCircle; - }; - - const getUseCaseColor = (title: string) => { - const colorMap: Record = { - 'Activate Multiple': 'text-green-600 dark:text-green-400', - 'Deactivate Multiple': 'text-red-600 dark:text-red-400', - 'Delete Expired': 'text-gray-600 dark:text-gray-400' - }; - return colorMap[title] || 'text-gray-600 dark:text-gray-400'; - }; - - const IconComponent = getUseCaseIcon(useCase.title); - const iconColor = getUseCaseColor(useCase.title); - - return ( -
- - {useCase.title}: {useCase.description} -
- ); - })} -
-
-
- )} -
- - {/* Property Selection Guide */} -
-
- -
- {(['en', 'fil'] as const).map((lang) => ( - - ))} -
-
- - {showPropertyGuide && ( -
-

{guideTranslations[guideLanguage].propertyGuide.description}

-
- {guideTranslations[guideLanguage].propertyGuide.tips.map((tip, idx) => ( -
-
{idx + 1}
-
-
{tip.title}
-

{tip.description}

-
-
- ))} -
-
- )} +
{/* Summary Cards */} -
+
{[ { label: "Total Discounts", value: String(totalCount), color: "bg-indigo-500", icon: Tag }, { label: "Active", value: String(activeCount), color: "bg-green-500", icon: CheckCircle }, @@ -747,11 +514,11 @@ const DiscountPage = () => { return (
-
+
-

{stat.label}

+

{stat.label}

{isLoading ? (
@@ -760,7 +527,7 @@ const DiscountPage = () => { )}
- +
); @@ -934,7 +701,14 @@ const DiscountPage = () => {
) : ( paginatedRows.map((row, index) => ( -
+
@@ -1058,7 +832,7 @@ const DiscountPage = () => { - - + @@ -1108,11 +882,11 @@ const DiscountPage = () => { className="border border-gray-200 dark:border-gray-700 animate-pulse" > {/* Select Checkbox */} - {/* Discount Info - Combined */} - {/* Value & Usage - Combined */} - {/* Status & Date - Combined */} - {/* Actions */} - ) : ( paginatedRows.map((row, index) => ( - - + - - - - @@ -1742,12 +1613,12 @@ export default function InventoryPage() {

@@ -1771,6 +1642,206 @@ export default function InventoryPage() {
+
{
handleSort("discount_code")} - className="text-left py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" + className="text-left py-2.5 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" >
Discount Info @@ -1080,7 +854,7 @@ const DiscountPage = () => {
handleSort("discount_value")} - className="text-left py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" + className="text-left py-2.5 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" >
Value & Usage @@ -1089,14 +863,14 @@ const DiscountPage = () => {
handleSort("active")} - className="text-center py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors whitespace-nowrap" + className="text-center py-2.5 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors whitespace-nowrap" >
Status & Date
ActionsActions
+
+
@@ -1120,7 +894,7 @@ const DiscountPage = () => {
+
@@ -1128,7 +902,7 @@ const DiscountPage = () => {
+
@@ -1136,7 +910,7 @@ const DiscountPage = () => {
+
@@ -1155,8 +929,15 @@ const DiscountPage = () => {
+
{ className="w-4 h-4 text-brand-primary border-gray-300 rounded focus:ring-brand-primary" /> +
{highlightText(row.discount_code, searchTerm)} @@ -1177,7 +958,7 @@ const DiscountPage = () => {
+
{highlightText(row.formatted_value, searchTerm)} @@ -1211,7 +992,7 @@ const DiscountPage = () => { )}
+
{
+
+ {/* Help & Guides Drawer */} + {showGuideDrawer && ( +
+ {/* Backdrop */} +
setShowGuideDrawer(false)} + /> + + {/* Panel */} +
+ {/* Drawer Header */} +
+
+ +

Help & Guides

+
+
+
+ {(['en', 'fil'] as const).map((lang) => ( + + ))} +
+ +
+
+ + {/* Tabs */} +
+ {([ + { key: 'status', label: 'Status' }, + { key: 'manage', label: 'Manage' }, + { key: 'bulk', label: 'Bulk' }, + { key: 'property', label: 'Property' }, + ] as const).map((tab) => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeGuideTab === 'status' && ( +
+

{guideTranslations[guideLanguage].statusGuide.title}

+
+ {guideTranslations[guideLanguage].statusGuide.statuses.map((status, idx) => { + const statusColors: Record = { + Active: { dot: 'bg-green-500' }, + Inactive: { dot: 'bg-red-500' } + }; + const color = statusColors[status.name] || { dot: 'bg-gray-500' }; + + return ( +
+
+
+
{status.name}
+

{status.description}

+
+
+ ); + })} +
+
+ )} + + {activeGuideTab === 'manage' && ( +
+

{guideTranslations[guideLanguage].discountGuide.title}

+
+ {guideTranslations[guideLanguage].discountGuide.steps.map((step, idx) => ( +
+
{idx + 1}
+
+
{step.title}
+

{step.description}

+
+
+ ))} +
+ +
+
{guideTranslations[guideLanguage].discountGuide.actionGuideTitle}
+
+ {guideTranslations[guideLanguage].discountGuide.actions.map((action, idx) => { + const getActionIconAndColor = (title: string) => { + const iconMap: Record = { + Active: { Icon: CheckCircle, color: 'text-green-600 dark:text-green-400' }, + Inactive: { Icon: XCircle, color: 'text-red-600 dark:text-red-400' } + }; + return iconMap[title]; + }; + const iconData = getActionIconAndColor(action.title); + + return ( +
+ {iconData && } + {action.title}: {action.description} +
+ ); + })} +
+
+
+ )} + + {activeGuideTab === 'bulk' && ( +
+

{guideTranslations[guideLanguage].bulkGuide.title}

+
+ {guideTranslations[guideLanguage].bulkGuide.steps.map((step, idx) => ( +
+
{idx + 1}
+
+
{step.title}
+

{step.description}

+
+
+ ))} +
+ +
+
{guideTranslations[guideLanguage].bulkGuide.whenToUseTitle}
+
+ {guideTranslations[guideLanguage].bulkGuide.useCases.map((useCase, idx) => { + const getUseCaseIcon = (title: string) => { + const iconMap: Record = { + 'Activate Multiple': CheckCircle, + 'Deactivate Multiple': XCircle, + 'Delete Expired': Trash2 + }; + return iconMap[title] || CheckCircle; + }; + + const getUseCaseColor = (title: string) => { + const colorMap: Record = { + 'Activate Multiple': 'text-green-600 dark:text-green-400', + 'Deactivate Multiple': 'text-red-600 dark:text-red-400', + 'Delete Expired': 'text-gray-600 dark:text-gray-400' + }; + return colorMap[title] || 'text-gray-600 dark:text-gray-400'; + }; + + const IconComponent = getUseCaseIcon(useCase.title); + const iconColor = getUseCaseColor(useCase.title); + + return ( +
+ + {useCase.title}: {useCase.description} +
+ ); + })} +
+
+
+ )} + + {activeGuideTab === 'property' && ( +
+

{guideTranslations[guideLanguage].propertyGuide.title}

+

{guideTranslations[guideLanguage].propertyGuide.description}

+
+ {guideTranslations[guideLanguage].propertyGuide.tips.map((tip, idx) => ( +
+
{idx + 1}
+
+
{tip.title}
+

{tip.description}

+
+
+ ))} +
+
+ )} +
+
+
+ )} + {/* Modals */} { const getUiStatus = (currentStock: number): InventoryStatus => { if (!Number.isFinite(currentStock) || currentStock <= 0) return "Out of Stock"; - if (currentStock <= 10) return "Low Stock"; + if (currentStock <= LOW_STOCK_THRESHOLD) return "Low Stock"; return "In Stock"; }; @@ -137,7 +144,7 @@ const guideTranslations = { }, { name: "Low Stock", - description: "Items are running low (at or below threshold of 10 units)" + description: "Items are running low (at or below threshold of 15 units)" }, { name: "Out of Stock", @@ -224,7 +231,7 @@ const guideTranslations = { }, { name: "Low Stock", - description: "Kaunting items na lang (10 units o mas kaunti)" + description: "Kaunting items na lang (15 units o mas kaunti)" }, { name: "Out of Stock", @@ -348,10 +355,9 @@ export default function InventoryPage() { const [rows, setRows] = useState([]); const [usageData, setUsageData] = useState([]); - // Guide states - const [showStatusGuide, setShowStatusGuide] = useState(false); - const [showUsageGuide, setShowUsageGuide] = useState(false); - const [showBulkGuide, setShowBulkGuide] = useState(false); + // Guide drawer states + const [showGuideDrawer, setShowGuideDrawer] = useState(false); + const [activeGuideTab, setActiveGuideTab] = useState<"status" | "manage" | "bulk">("status"); const [guideLanguage, setGuideLanguage] = useState<"en" | "fil">("en"); const loadInventory = async () => { @@ -544,6 +550,37 @@ export default function InventoryPage() { const lowStockCount = rows.filter((r) => r.status === "Low Stock").length; const outOfStockCount = rows.filter((r) => r.status === "Out of Stock").length; + // Items that need restocking (low or out of stock) + const lowStockItems = useMemo( + () => rows.filter((r) => r.current_stock > 0 && r.current_stock <= LOW_STOCK_THRESHOLD), + [rows], + ); + const outOfStockItems = useMemo( + () => rows.filter((r) => r.current_stock <= 0), + [rows], + ); + + // Show a warning toast once when low/out-of-stock items are detected after loading + const lowStockWarnedRef = useRef(false); + useEffect(() => { + if (loading) return; + + const affected = [...outOfStockItems, ...lowStockItems]; + if (affected.length === 0) { + lowStockWarnedRef.current = false; // reset so a future drop warns again + return; + } + if (lowStockWarnedRef.current) return; + lowStockWarnedRef.current = true; + + const names = affected.slice(0, 3).map((r) => r.item_name).join(", "); + const extra = affected.length > 3 ? ` +${affected.length - 3} more` : ""; + toast.error( + `⚠️ ${affected.length} item(s) running low (≤ ${LOW_STOCK_THRESHOLD} pcs): ${names}${extra}`, + { id: "inventory-low-stock", duration: 6000 }, + ); + }, [loading, lowStockItems, outOfStockItems]); + const categoryOptions = CATEGORY_FILTER_OPTIONS; const getExportRows = () => sortedRows; @@ -716,7 +753,7 @@ export default function InventoryPage() { return (
-
+

Inventory Management @@ -725,6 +762,14 @@ export default function InventoryPage() { Manage items, stock levels, and usage tracking

+
{isAddItemOpen && ( @@ -738,7 +783,7 @@ export default function InventoryPage() { !Number.isFinite(item.current_stock) || item.current_stock <= 0 ? "Out of Stock" - : item.current_stock <= 10 + : item.current_stock <= LOW_STOCK_THRESHOLD ? "Low Stock" : "In Stock"; @@ -818,215 +863,6 @@ export default function InventoryPage() { /> )} - {/* Status Guide */} -
-
- -
- {(['en', 'fil'] as const).map((lang) => ( - - ))} -
-
- - {showStatusGuide && ( -
- {guideTranslations[guideLanguage].statusGuide.statuses.map((status, idx) => { - const statusColors: Record = { - "In Stock": "bg-green-500", - "Low Stock": "bg-yellow-500", - "Out of Stock": "bg-red-500" - }; - const color = statusColors[status.name] || "bg-gray-500"; - - return ( -
-
-
-
{status.name}
-

{status.description}

-
-
- ); - })} -
- )} -
- - {/* How to Use Inventory Guide */} -
-
- -
- {(['en', 'fil'] as const).map((lang) => ( - - ))} -
-
- - {showUsageGuide && ( -
-
- {guideTranslations[guideLanguage].usageGuide.steps.map((step, idx) => ( -
-
{idx + 1}
-
-
{step.title}
-

{step.description}

-
-
- ))} -
- -
-
{guideTranslations[guideLanguage].usageGuide.actionGuideTitle}
-
- {guideTranslations[guideLanguage].usageGuide.actions.map((action, idx) => { - const getActionIcon = (title: string) => { - const iconMap: Record = { - View: Eye, - Edit: Edit, - Delete: Trash2 - }; - return iconMap[title] || Eye; - }; - - const getActionColor = (title: string) => { - const colorMap: Record = { - View: 'text-blue-600 dark:text-blue-400', - Edit: 'text-indigo-600 dark:text-indigo-400', - Delete: 'text-red-600 dark:text-red-400' - }; - return colorMap[title] || 'text-gray-600 dark:text-gray-400'; - }; - - const IconComponent = getActionIcon(action.title); - const iconColor = getActionColor(action.title); - - return ( -
- - {action.title}: {action.description} -
- ); - })} -
-
-
- )} -
- - {/* Bulk Operations Guide */} -
-
- -
- {(['en', 'fil'] as const).map((lang) => ( - - ))} -
-
- - {showBulkGuide && ( -
-
- {guideTranslations[guideLanguage].bulkGuide.steps.map((step, idx) => ( -
-
{idx + 1}
-
-
{step.title}
-

{step.description}

-
-
- ))} -
- -
-
{guideTranslations[guideLanguage].bulkGuide.whenToUseTitle}
-
- {guideTranslations[guideLanguage].bulkGuide.useCases.map((useCase, idx) => { - const getUseCaseIcon = (title: string) => { - const iconMap: Record = { - 'Use Filters': Filter, - 'Export Reports': FileDown, - 'Monitor Usage': Activity - }; - return iconMap[title] || AlertCircle; - }; - - const getUseCaseColor = (title: string) => { - const colorMap: Record = { - 'Use Filters': 'text-blue-600 dark:text-blue-400', - 'Export Reports': 'text-green-600 dark:text-green-400', - 'Monitor Usage': 'text-indigo-600 dark:text-indigo-400' - }; - return colorMap[title] || 'text-gray-600 dark:text-gray-400'; - }; - - const IconComponent = getUseCaseIcon(useCase.title); - const iconColor = getUseCaseColor(useCase.title); - - return ( -
- - {useCase.title}: {useCase.description} -
- ); - })} -
-
-
- )} -
-
{[ { @@ -1058,11 +894,11 @@ export default function InventoryPage() { return (
-
+
-

{stat.label}

+

{stat.label}

{loading ? (
@@ -1071,7 +907,7 @@ export default function InventoryPage() { )}
- +
); @@ -1197,6 +1033,41 @@ export default function InventoryPage() {
)} + {/* Low Stock Warning */} + {!loading && (lowStockItems.length > 0 || outOfStockItems.length > 0) && ( +
+
+ +
+

+ Low Stock Warning +

+

+ {outOfStockItems.length + lowStockItems.length} item(s) need restocking (at or below {LOW_STOCK_THRESHOLD} pcs). +

+
+ {outOfStockItems.map((item) => ( + + {item.item_name}: Out of stock + + ))} + {lowStockItems.map((item) => ( + + {item.item_name}: {item.current_stock} pcs left + + ))} +
+
+
+
+ )} +
{loading ? ( @@ -1227,7 +1098,7 @@ export default function InventoryPage() { ) : ( paginatedRows.map((row) => (
-
+
{highlightText(row.item_id, searchTerm)}
@@ -1239,17 +1110,17 @@ export default function InventoryPage() {
Stock
-
{row.current_stock}
+
{row.current_stock}
Unit
-
{row.unit_type}
+
{row.unit_type}
Price
-
+
{row.price_per_unit > 0 ? `₱${row.price_per_unit.toFixed(2)}` : "—"}
@@ -1463,39 +1334,39 @@ export default function InventoryPage() { className="border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" >
- + {row.item_id} - + {row.item_name} - + {row.category} - + {row.current_stock} - + {row.unit_type} - + {row.price_per_unit > 0 ? `₱${row.price_per_unit.toFixed(2)}` : ""} - + {formatDateTime(row.last_restocked)} - + {u.used_today} - + {u.used_week}
+ + {/* Help & Guides Drawer */} + {showGuideDrawer && ( +
+ {/* Backdrop */} +
setShowGuideDrawer(false)} + /> + + {/* Panel */} +
+ {/* Drawer Header */} +
+
+ +

Help & Guides

+
+
+
+ {(['en', 'fil'] as const).map((lang) => ( + + ))} +
+ +
+
+ + {/* Tabs */} +
+ {([ + { key: 'status', label: 'Status' }, + { key: 'manage', label: 'Manage' }, + { key: 'bulk', label: 'Bulk' }, + ] as const).map((tab) => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeGuideTab === 'status' && ( +
+

{guideTranslations[guideLanguage].statusGuide.title}

+
+ {guideTranslations[guideLanguage].statusGuide.statuses.map((status, idx) => { + const statusColors: Record = { + "In Stock": "bg-green-500", + "Low Stock": "bg-yellow-500", + "Out of Stock": "bg-red-500" + }; + const color = statusColors[status.name] || "bg-gray-500"; + + return ( +
+
+
+
{status.name}
+

{status.description}

+
+
+ ); + })} +
+
+ )} + + {activeGuideTab === 'manage' && ( +
+

{guideTranslations[guideLanguage].usageGuide.title}

+
+ {guideTranslations[guideLanguage].usageGuide.steps.map((step, idx) => ( +
+
{idx + 1}
+
+
{step.title}
+

{step.description}

+
+
+ ))} +
+ +
+
{guideTranslations[guideLanguage].usageGuide.actionGuideTitle}
+
+ {guideTranslations[guideLanguage].usageGuide.actions.map((action, idx) => { + const getActionIcon = (title: string) => { + const iconMap: Record = { + View: Eye, + Edit: Edit, + Delete: Trash2 + }; + return iconMap[title] || Eye; + }; + + const getActionColor = (title: string) => { + const colorMap: Record = { + View: 'text-blue-600 dark:text-blue-400', + Edit: 'text-indigo-600 dark:text-indigo-400', + Delete: 'text-red-600 dark:text-red-400' + }; + return colorMap[title] || 'text-gray-600 dark:text-gray-400'; + }; + + const IconComponent = getActionIcon(action.title); + const iconColor = getActionColor(action.title); + + return ( +
+ + {action.title}: {action.description} +
+ ); + })} +
+
+
+ )} + + {activeGuideTab === 'bulk' && ( +
+

{guideTranslations[guideLanguage].bulkGuide.title}

+
+ {guideTranslations[guideLanguage].bulkGuide.steps.map((step, idx) => ( +
+
{idx + 1}
+
+
{step.title}
+

{step.description}

+
+
+ ))} +
+ +
+
{guideTranslations[guideLanguage].bulkGuide.whenToUseTitle}
+
+ {guideTranslations[guideLanguage].bulkGuide.useCases.map((useCase, idx) => { + const getUseCaseIcon = (title: string) => { + const iconMap: Record = { + 'Use Filters': Filter, + 'Export Reports': FileDown, + 'Monitor Usage': Activity + }; + return iconMap[title] || AlertCircle; + }; + + const getUseCaseColor = (title: string) => { + const colorMap: Record = { + 'Use Filters': 'text-blue-600 dark:text-blue-400', + 'Export Reports': 'text-green-600 dark:text-green-400', + 'Monitor Usage': 'text-indigo-600 dark:text-indigo-400' + }; + return colorMap[title] || 'text-gray-600 dark:text-gray-400'; + }; + + const IconComponent = getUseCaseIcon(useCase.title); + const iconColor = getUseCaseColor(useCase.title); + + return ( +
+ + {useCase.title}: {useCase.description} +
+ ); + })} +
+
+
+ )} +
+
+
+ )}
); diff --git a/Components/admin/Csr/Modals/ViewPaymentModal.tsx b/Components/admin/Csr/Modals/ViewPaymentModal.tsx index 60b6be54..ed197d71 100644 --- a/Components/admin/Csr/Modals/ViewPaymentModal.tsx +++ b/Components/admin/Csr/Modals/ViewPaymentModal.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { X, CreditCard, User, ExternalLink, MapPin } from "lucide-react"; import type { PaymentRow } from "../types"; -import { formatDate } from "../utils"; +import { formatDate, formatCurrency } from "../utils"; interface ViewPaymentModalProps { isOpen: boolean; @@ -12,6 +12,26 @@ interface ViewPaymentModalProps { payment: PaymentRow | null; } +/** + * Loose shape for the booking object shown in this modal. Covers exactly the + * fields read below — some (haven, status, created_at) aren't on the strict + * PaymentRow["booking"] type, so we widen with these optional fields instead + * of falling back to `any`. + */ +type ViewBookingInfo = { + status?: string | null; + haven?: string | null; + booking_id?: string | null; + created_at?: string | null; + updated_at?: string | null; + guest_first_name?: string | null; + guest_last_name?: string | null; + guest_email?: string | null; + guest_phone?: string | null; + payment_proof_url?: string | null; + payment_method?: string | null; +}; + const getStatusColorClass = (status?: string | null) => { const s = (status || "").toLowerCase(); if (s === "approved" || s === "confirmed") @@ -57,7 +77,7 @@ export default function ViewPaymentModal({ if (!isOpen || !payment) return null; - const booking: any = payment.booking ?? {}; + const booking = (payment.booking ?? {}) as ViewBookingInfo; const statusSource = booking.status ?? payment.status; const statusClass = getStatusColorClass(statusSource); @@ -217,6 +237,40 @@ export default function ViewPaymentModal({
+ + {/* Payment Breakdown */} +
+
+ + Payment Breakdown +
+
+
+ Room + {formatCurrency(payment.roomRate ?? 0)} +
+
+ Security Deposit + {formatCurrency(payment.security_deposit ?? 0)} +
+
+ Add-ons + {formatCurrency(payment.addOnsTotal ?? 0)} +
+
+ Total + {payment.totalAmount} +
+
+ Down Payment + {payment.downPayment} +
+
+ Balance + {payment.remaining} +
+
+
{/* Footer */} diff --git a/Components/admin/Csr/PaymentPage.tsx b/Components/admin/Csr/PaymentPage.tsx index b67678dd..28f140d7 100644 --- a/Components/admin/Csr/PaymentPage.tsx +++ b/Components/admin/Csr/PaymentPage.tsx @@ -21,7 +21,7 @@ import { CheckSquare, Loader2, } from "lucide-react"; -import { useCallback, useMemo, useState, useEffect } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useSession } from "next-auth/react"; import toast from "react-hot-toast"; import type { UpdateBookingPaymentPayload } from "@/types/bookingPayment"; @@ -35,7 +35,6 @@ import ApproveModal from "./Modals/ApproveModal"; import RejectModal from "./Modals/RejectModal"; import ChangeModal from "./Modals/ChangeModal"; import ViewPaymentModal from "./Modals/ViewPaymentModal"; -import TotalBreakdown from "./TotalBreakdown"; import { GuestDetailsColumn, DateRangeWithDays, BookingIdWithProof } from "./Column"; // Payment types are imported from ./types @@ -71,17 +70,17 @@ const TableSkeleton = ({ rows = 5 }: { rows?: number }) => ( {Array.from({ length: rows }).map((_, i) => (
+
+
+
@@ -90,7 +89,7 @@ const TableSkeleton = ({ rows = 5 }: { rows?: number }) => (
+
@@ -98,7 +97,7 @@ const TableSkeleton = ({ rows = 5 }: { rows?: number }) => (
+
@@ -106,12 +105,12 @@ const TableSkeleton = ({ rows = 5 }: { rows?: number }) => (
+
+
@@ -129,7 +128,7 @@ const TableSkeleton = ({ rows = 5 }: { rows?: number }) => ( // ChangeModal component moved to ./Modals/ChangeModal -export default function PaymentPage() { +export default function PaymentPage({ hideSummary = false }: { hideSummary?: boolean } = {}) { const [searchTerm, setSearchTerm] = useState(""); const [filterStatus, setFilterStatus] = useState<"all" | PaymentStatus>( "all", @@ -419,11 +418,9 @@ export default function PaymentPage() { const onSearchChange = (value: string) => { setSearchTerm(value); - }; - - useEffect(() => { + // Reset to the first page whenever the search term changes setCurrentPage(1); - }, [searchTerm]); + }; const openRejectModal = useCallback( (row: PaymentRow) => { @@ -760,7 +757,7 @@ export default function PaymentPage() {

- Payments Management + Down Payment

Review and manage payment submissions @@ -768,6 +765,7 @@ export default function PaymentPage() {

+ {!hideSummary && (
{[ { @@ -799,19 +797,20 @@ export default function PaymentPage() { return (
-
+
-

{stat.label}

+

{stat.label}

{stat.value}

- +
); })}
+ )} {/* Bulk Actions Bar */} {selectedPayments.length > 0 && ( @@ -1019,7 +1018,7 @@ export default function PaymentPage() { - - - - - @@ -1074,12 +1073,20 @@ export default function PaymentPage() { ) : ( - {paginatedPayments.map((payment) => ( + {paginatedPayments.map((payment) => { + const isSelected = payment.id + ? selectedPayments.includes(payment.id) + : false; + return ( - - - - - - - - ))} + ); + })} )}
+
handleSort("booking_id")} - className="text-left py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap border border-gray-200 dark:border-gray-700" + className="text-left py-2.5 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap border border-gray-200 dark:border-gray-700" >
Booking ID
+ Guest Details + Check-in / Check-out handleSort("totalAmount")} - className="text-right py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap border border-gray-200 dark:border-gray-700" + className="text-right py-2.5 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap border border-gray-200 dark:border-gray-700" >
Total @@ -1062,10 +1061,10 @@ export default function PaymentPage() {
+ Payment Proof & Method + Actions
+ + {payment.booking_id} + + - + +
+
+ {payment.totalAmount} +
+
+ Down payment: {payment.downPayment} +
+ {(payment.remainingValue ?? 0) > 0 && ( +
+ Balance: {payment.remaining} +
+ )} +
+
{payment.payment_method && ( @@ -1174,7 +1183,7 @@ export default function PaymentPage() {
+
@@ -1323,7 +1333,11 @@ export default function PaymentPage() { paginatedPayments.map((payment) => (
@@ -1376,17 +1390,19 @@ export default function PaymentPage() {

Total

- +
+
+ {payment.totalAmount} +
+
+ Down payment: {payment.downPayment} +
+ {(payment.remainingValue ?? 0) > 0 && ( +
+ Balance: {payment.remaining} +
+ )} +
diff --git a/Components/admin/Csr/PaymentsHub.tsx b/Components/admin/Csr/PaymentsHub.tsx new file mode 100644 index 00000000..32d04026 --- /dev/null +++ b/Components/admin/Csr/PaymentsHub.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + DollarSign, + CheckCircle, + Clock, + XCircle, + Wallet, + Shield, + Banknote, +} from "lucide-react"; +import { useGetBookingPaymentsQuery } from "@/redux/api/bookingPaymentsApi"; +import { getDeposits, DepositRecord } from "@/app/admin/csr/actions"; +import PaymentPage from "./PaymentPage.tsx"; +import DepositPage from "./DepositPage"; + +type PaymentCategory = "downpayment" | "deposit"; + +/** + * PaymentsHub + * Single "Payments" tab that combines the Guest Down Payment and Guest + * Security Deposit views. Shows ONE complete row of 7 KPI cards (drawn from + * both data sources) at the top, then category buttons that switch only the + * table below. Each underlying page keeps its own data, IDs, modals and bulk + * actions intact — nothing is merged. The pages' own KPI rows are hidden + * (hideSummary) so they don't duplicate the combined row. + */ +export default function PaymentsHub() { + const [category, setCategory] = useState("downpayment"); + + // --- Down payment data (same hook the Payment page uses, unfiltered) --- + const { data: paymentsAll = [] } = useGetBookingPaymentsQuery(); + + // --- Security deposit data (same server action the Deposit page uses) --- + const [deposits, setDeposits] = useState([]); + useEffect(() => { + let active = true; + getDeposits() + .then((data) => { + if (active) setDeposits(data); + }) + .catch((err) => console.error("Failed to load deposits for KPIs:", err)); + return () => { + active = false; + }; + }, []); + + const formatCurrency = (amount: number) => + new Intl.NumberFormat("en-PH", { + style: "currency", + currency: "PHP", + minimumFractionDigits: 0, + }).format(amount); + + // --- Combined KPI calculations (counts/sums only — no ID merging) --- + const { downPaymentKpis, depositKpis } = useMemo(() => { + const all = paymentsAll || []; + const totalPayments = all.length; + const dpApproved = all.filter( + (p) => p.payment_status === "approved_down_payment", + ).length; + const dpPending = all.filter( + (p) => p.payment_status === "pending_down_payment", + ).length; + const rejected = all.filter( + (p) => p.payment_status === "rejected", + ).length; + + const pendingDeposits = deposits.filter((d) => d.status === "Pending").length; + const paidDeposits = deposits.filter( + (d) => d.status === "Paid" || d.status === "Held", + ); + const totalHeld = paidDeposits.reduce( + (sum, d) => sum + (d.deposit_amount || 0), + 0, + ); + + return { + downPaymentKpis: [ + { label: "Total Payments", value: String(totalPayments), color: "bg-green-500", icon: DollarSign }, + { label: "Down payment approved", value: String(dpApproved), color: "bg-emerald-500", icon: CheckCircle }, + { label: "Down payment pending", value: String(dpPending), color: "bg-yellow-500", icon: Clock }, + { label: "Rejected", value: String(rejected), color: "bg-red-500", icon: XCircle }, + ], + depositKpis: [ + { label: "Pending Deposits", value: String(pendingDeposits), color: "bg-amber-500", icon: Wallet }, + { label: "Paid (Holding)", value: String(paidDeposits.length), color: "bg-indigo-500", icon: Shield }, + { label: "Total Held Amount", value: formatCurrency(totalHeld), color: "bg-teal-500", icon: Banknote }, + ], + }; + }, [paymentsAll, deposits]); + + const renderKpiCard = ( + stat: { label: string; value: string; color: string; icon: typeof DollarSign }, + i: number, + ) => { + const IconComponent = stat.icon; + return ( +
+
+
+

{stat.label}

+

{stat.value}

+
+ +
+
+ ); + }; + + const buttons: { id: PaymentCategory; label: string; icon: typeof DollarSign }[] = [ + { id: "downpayment", label: "Down Payment", icon: DollarSign }, + { id: "deposit", label: "Security Deposit", icon: Wallet }, + ]; + + return ( +
+ {/* Page heading */} +
+

+ Payments +

+

+ Manage guest down payments and security deposits +

+
+ + {/* Combined KPI cards — grouped by category so full titles fit */} +
+ {/* Down Payment KPIs */} +
+

+ Down Payment +

+
+ {downPaymentKpis.map(renderKpiCard)} +
+
+ + {/* Security Deposit KPIs */} +
+

+ Security Deposit +

+
+ {depositKpis.map(renderKpiCard)} +
+
+
+ + {/* Category Buttons */} +
+ {buttons.map((btn) => { + const Icon = btn.icon; + const active = category === btn.id; + return ( + + ); + })} +
+ + {/* Active Category Table (each page's own KPI row hidden via hideSummary) */} +
+ {category === "downpayment" ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/Components/admin/Owners/PartnerManagementPage.tsx b/Components/admin/Owners/PartnerManagementPage.tsx index 51154358..fa7c08bb 100644 --- a/Components/admin/Owners/PartnerManagementPage.tsx +++ b/Components/admin/Owners/PartnerManagementPage.tsx @@ -2613,7 +2613,7 @@ const formatTime = (t?: string | null): string => { }; const TimeRangeBox = ({ label, start, end }: { label: string; start?: string | null; end?: string | null }) => ( -
+

{label}

{formatTime(start)} diff --git a/package-lock.json b/package-lock.json index 131da2ab..0133d1a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -445,6 +445,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -461,6 +462,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -477,6 +479,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -493,6 +496,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -509,6 +513,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -525,6 +530,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -541,6 +547,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -557,6 +564,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -573,6 +581,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -589,6 +598,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -605,6 +615,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -621,6 +632,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -637,6 +649,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -653,6 +666,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -669,6 +683,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -685,6 +700,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -701,6 +717,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -717,6 +734,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -733,6 +751,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -749,6 +768,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -765,6 +785,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -781,6 +802,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -797,6 +819,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -813,6 +836,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -829,6 +853,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -845,6 +870,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11774,7 +11800,9 @@ }, "node_modules/react-icons": { "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "license": "MIT", "peerDependencies": { "react": "*" } From ecd4b34a6c1b804ac4f04f4bff4c960e2afe1a91 Mon Sep 17 00:00:00 2001 From: roje1030 Date: Thu, 4 Jun 2026 16:29:59 +0800 Subject: [PATCH 2/5] // All of it except Calendar and Booking is fixed. --- Components/admin/Csr/CsrDashboardPage.tsx | 77 ++-- Components/admin/Csr/DepositPage.tsx | 413 +++++++++--------- Components/admin/Csr/InventoryPage.tsx | 71 ++- Components/admin/Csr/Modals/MessageModal.tsx | 12 +- Components/admin/Csr/Modals/Notification.tsx | 32 +- Components/admin/Csr/SettingsPage.tsx | 213 ++++----- Components/admin/Owners/Modals/AdminLogin.tsx | 124 ++++++ app/api/admin/alerts/low-inventory/route.ts | 38 ++ app/api/admin/settings/csr/mfa/route.ts | 60 +++ app/api/admin/settings/csr/route.ts | 5 +- backend/models/employee.sql | 1 + .../models/migrations/add_employee_mfa.sql | 9 + backend/utils/mailer.ts | 96 ++++ lib/auth.ts | 84 +++- 14 files changed, 831 insertions(+), 404 deletions(-) create mode 100644 app/api/admin/alerts/low-inventory/route.ts create mode 100644 app/api/admin/settings/csr/mfa/route.ts create mode 100644 backend/models/migrations/add_employee_mfa.sql diff --git a/Components/admin/Csr/CsrDashboardPage.tsx b/Components/admin/Csr/CsrDashboardPage.tsx index 669ec5fd..fce0883f 100644 --- a/Components/admin/Csr/CsrDashboardPage.tsx +++ b/Components/admin/Csr/CsrDashboardPage.tsx @@ -1,6 +1,6 @@ "use client"; -import { Menu, X, Home, Calendar, CalendarDays, DollarSign, FileText, Users, Package, Settings, Bell, ChevronDown, User, MessageSquare, BarChart3, Headphones, Moon, Sun, Monitor, Cloud, CloudRain, CloudSnow, Activity, Tag, Globe } from "lucide-react"; +import { Menu, X, Home, Calendar, CalendarDays, DollarSign, FileText, Users, Package, Settings, Bell, ChevronDown, User, MessageSquare, BarChart3, Headphones, Moon, Sun, Monitor, Cloud, CloudRain, CloudSnow, Activity, Tag, Globe, LogOut } from "lucide-react"; import Image from "next/image"; import { useMemo, useState, useEffect, useRef } from "react"; import { useSession, signOut } from "next-auth/react"; @@ -837,54 +837,32 @@ export default function CsrDashboard() {
- {/* Theme Toggle */} -
+ {/* Appearance / Theme */} +
+ Appearance
- - - + {([ + { value: "light", Icon: Sun, label: "Light" }, + { value: "dark", Icon: Moon, label: "Dark" }, + { value: "system", Icon: Monitor, label: "System" }, + ] as const).map(({ value, Icon, label }) => ( + + ))}
@@ -897,7 +875,7 @@ export default function CsrDashboard() { }} className="w-full px-4 py-2.5 flex items-center gap-3 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors duration-150 text-left" > - + Sign Out
@@ -943,6 +921,7 @@ export default function CsrDashboard() { {notificationOpen && ( setNotificationOpen(false)} onViewAll={() => { setNotificationOpen(false); diff --git a/Components/admin/Csr/DepositPage.tsx b/Components/admin/Csr/DepositPage.tsx index cfe0f870..cb238eb7 100644 --- a/Components/admin/Csr/DepositPage.tsx +++ b/Components/admin/Csr/DepositPage.tsx @@ -26,7 +26,9 @@ import { Play, XCircle, Download, - Shield + Shield, + HelpCircle, + X } from "lucide-react"; import { useMemo, useState, useEffect } from "react"; import { useSession } from "next-auth/react"; @@ -314,9 +316,8 @@ export default function DepositPage({ hideSummary = false }: { hideSummary?: boo const [isBulkForfeitedModalOpen, setIsBulkForfeitedModalOpen] = useState(false); const [isBulkPartialModalOpen, setIsBulkPartialModalOpen] = useState(false); const [bulkAction, setBulkAction] = useState(""); - const [showStatusGuide, setShowStatusGuide] = useState(false); - const [showDepositGuide, setShowDepositGuide] = useState(false); - const [showBulkGuide, setShowBulkGuide] = useState(false); + const [showGuideDrawer, setShowGuideDrawer] = useState(false); + const [activeGuideTab, setActiveGuideTab] = useState<"status" | "manage" | "bulk">("status"); const [guideLanguage, setGuideLanguage] = useState<"en" | "fil">("en"); const fetchData = async () => { @@ -932,211 +933,19 @@ export default function DepositPage({ hideSummary = false }: { hideSummary?: boo return (
{/* Header */} -
+

Security Deposit

Manage security deposits for bookings

-
- - {/* Status Guide */} -
-
- -
- {(['en', 'fil'] as const).map((lang) => ( - - ))} -
-
- - {showStatusGuide && ( -
- {guideTranslations[guideLanguage].statusGuide.statuses.map((status, idx) => { - const statusColors: Record = { - Pending: { dot: 'bg-yellow-500' }, - Paid: { dot: 'bg-indigo-500' }, - Returned: { dot: 'bg-green-500' }, - Partial: { dot: 'bg-orange-500' }, - Forfeited: { dot: 'bg-red-500' } - }; - const color = statusColors[status.name] || { dot: 'bg-gray-500' }; - - return ( -
-
-
-
{status.name}
-

{status.description}

-
-
- ); - })} -
- )} -
- - {/* How to Manage Security Deposits Guide */} -
-
- -
- {(['en', 'fil'] as const).map((lang) => ( - - ))} -
-
- - {showDepositGuide && ( -
-
- {guideTranslations[guideLanguage].depositGuide.steps.map((step, idx) => ( -
-
{idx + 1}
-
-
{step.title}
-

{step.description}

-
-
- ))} -
- -
-
{guideTranslations[guideLanguage].depositGuide.actionGuideTitle}
-
- {guideTranslations[guideLanguage].depositGuide.actions.map((action, idx) => { - const getActionIconAndColor = (title: string) => { - const iconMap: Record = { - Returned: { Icon: CheckCircle, color: 'text-green-600 dark:text-green-400' }, - Partial: { Icon: RotateCcw, color: 'text-orange-600 dark:text-orange-400' }, - Forfeited: { Icon: XCircle, color: 'text-red-600 dark:text-red-400' }, - Held: { Icon: Play, color: 'text-indigo-600 dark:text-indigo-400' } - }; - return iconMap[title]; - }; - const iconData = getActionIconAndColor(action.title); - - return ( -
- {iconData && } - {action.title}: {action.description} -
- ); - })} -
-
-
- )} -
- - {/* Bulk Operations Guide */} -
-
- -
- {(['en', 'fil'] as const).map((lang) => ( - - ))} -
-
- - {showBulkGuide && ( -
-
- {guideTranslations[guideLanguage].bulkGuide.steps.map((step, idx) => ( -
-
{idx + 1}
-
-
{step.title}
-

{step.description}

-
-
- ))} -
- -
-
{guideTranslations[guideLanguage].bulkGuide.whenToUseTitle}
-
- {guideTranslations[guideLanguage].bulkGuide.useCases.map((useCase, idx) => { - const getUseCaseIcon = (title: string) => { - const iconMap: Record = { - 'Mark as Paid': Play, - 'Mark as Returned': CheckCircle, - 'Mark as Partial': RotateCcw, - 'Mark as Forfeited': XCircle - }; - return iconMap[title] || Play; - }; - - const getUseCaseColor = (title: string) => { - const colorMap: Record = { - 'Mark as Paid': 'text-indigo-600 dark:text-indigo-400', - 'Mark as Returned': 'text-green-600 dark:text-green-400', - 'Mark as Partial': 'text-orange-600 dark:text-orange-400', - 'Mark as Forfeited': 'text-red-600 dark:text-red-400' - }; - return colorMap[title] || 'text-gray-600 dark:text-gray-400'; - }; - - const IconComponent = getUseCaseIcon(useCase.title); - const iconColor = getUseCaseColor(useCase.title); - - return ( -
- - {useCase.title}: {useCase.description} -
- ); - })} -
-
-
- )} +
{/* Summary Cards */} @@ -2088,6 +1897,200 @@ export default function DepositPage({ hideSummary = false }: { hideSummary?: boo
+ {/* Help & Guides Drawer */} + {showGuideDrawer && ( +
+ {/* Backdrop */} +
setShowGuideDrawer(false)} + /> + + {/* Panel */} +
+ {/* Drawer Header */} +
+
+ +

Help & Guides

+
+
+
+ {(['en', 'fil'] as const).map((lang) => ( + + ))} +
+ +
+
+ + {/* Tabs */} +
+ {([ + { key: 'status', label: 'Status' }, + { key: 'manage', label: 'Manage' }, + { key: 'bulk', label: 'Bulk' }, + ] as const).map((tab) => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeGuideTab === 'status' && ( +
+

{guideTranslations[guideLanguage].statusGuide.title}

+
+ {guideTranslations[guideLanguage].statusGuide.statuses.map((status, idx) => { + const statusColors: Record = { + Pending: 'bg-yellow-500', + Paid: 'bg-indigo-500', + Returned: 'bg-green-500', + Partial: 'bg-orange-500', + Forfeited: 'bg-red-500' + }; + const color = statusColors[status.name] || 'bg-gray-500'; + + return ( +
+
+
+
{status.name}
+

{status.description}

+
+
+ ); + })} +
+
+ )} + + {activeGuideTab === 'manage' && ( +
+

{guideTranslations[guideLanguage].depositGuide.title}

+
+ {guideTranslations[guideLanguage].depositGuide.steps.map((step, idx) => ( +
+
{idx + 1}
+
+
{step.title}
+

{step.description}

+
+
+ ))} +
+ +
+
{guideTranslations[guideLanguage].depositGuide.actionGuideTitle}
+
+ {guideTranslations[guideLanguage].depositGuide.actions.map((action, idx) => { + const getActionIconAndColor = (title: string) => { + const iconMap: Record = { + Returned: { Icon: CheckCircle, color: 'text-green-600 dark:text-green-400' }, + Partial: { Icon: RotateCcw, color: 'text-orange-600 dark:text-orange-400' }, + Forfeited: { Icon: XCircle, color: 'text-red-600 dark:text-red-400' }, + Held: { Icon: Play, color: 'text-indigo-600 dark:text-indigo-400' } + }; + return iconMap[title]; + }; + const iconData = getActionIconAndColor(action.title); + + return ( +
+ {iconData && } + {action.title}: {action.description} +
+ ); + })} +
+
+
+ )} + + {activeGuideTab === 'bulk' && ( +
+

{guideTranslations[guideLanguage].bulkGuide.title}

+
+ {guideTranslations[guideLanguage].bulkGuide.steps.map((step, idx) => ( +
+
{idx + 1}
+
+
{step.title}
+

{step.description}

+
+
+ ))} +
+ +
+
{guideTranslations[guideLanguage].bulkGuide.whenToUseTitle}
+
+ {guideTranslations[guideLanguage].bulkGuide.useCases.map((useCase, idx) => { + const getUseCaseIcon = (title: string) => { + const iconMap: Record = { + 'Mark as Paid': Play, + 'Mark as Returned': CheckCircle, + 'Mark as Partial': RotateCcw, + 'Mark as Forfeited': XCircle + }; + return iconMap[title] || Play; + }; + + const getUseCaseColor = (title: string) => { + const colorMap: Record = { + 'Mark as Paid': 'text-indigo-600 dark:text-indigo-400', + 'Mark as Returned': 'text-green-600 dark:text-green-400', + 'Mark as Partial': 'text-orange-600 dark:text-orange-400', + 'Mark as Forfeited': 'text-red-600 dark:text-red-400' + }; + return colorMap[title] || 'text-gray-600 dark:text-gray-400'; + }; + + const IconComponent = getUseCaseIcon(useCase.title); + const iconColor = getUseCaseColor(useCase.title); + + return ( +
+ + {useCase.title}: {useCase.description} +
+ ); + })} +
+
+
+ )} +
+
+
+ )} + {/* Bulk Processing Modals */} { + let active = true; + fetch("/api/admin/settings/csr") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + const prefs = data?.notificationPrefs; + if (!active || !prefs) return; + if (typeof prefs.lowInventoryAlerts === "boolean") setAlertsEnabled(prefs.lowInventoryAlerts); + if (typeof prefs.push === "boolean") setPushEnabled(prefs.push); + if (typeof prefs.email === "boolean") setEmailEnabled(prefs.email); + }) + .catch(() => { + /* keep defaults (on) if settings can't be loaded */ + }); + return () => { + active = false; + }; + }, []); + + // Deliver low-stock alerts (push toast + email) once per detection after loading const lowStockWarnedRef = useRef(false); + const emailSentRef = useRef(false); useEffect(() => { if (loading) return; + if (!alertsEnabled) return; const affected = [...outOfStockItems, ...lowStockItems]; if (affected.length === 0) { - lowStockWarnedRef.current = false; // reset so a future drop warns again + // healthy again — re-arm both deliveries for the next drop + lowStockWarnedRef.current = false; + emailSentRef.current = false; return; } - if (lowStockWarnedRef.current) return; - lowStockWarnedRef.current = true; - const names = affected.slice(0, 3).map((r) => r.item_name).join(", "); - const extra = affected.length > 3 ? ` +${affected.length - 3} more` : ""; - toast.error( - `⚠️ ${affected.length} item(s) running low (≤ ${LOW_STOCK_THRESHOLD} pcs): ${names}${extra}`, - { id: "inventory-low-stock", duration: 6000 }, - ); - }, [loading, lowStockItems, outOfStockItems]); + // Push delivery → in-app toast notification + if (pushEnabled && !lowStockWarnedRef.current) { + lowStockWarnedRef.current = true; + const names = affected.slice(0, 3).map((r) => r.item_name).join(", "); + const extra = affected.length > 3 ? ` +${affected.length - 3} more` : ""; + toast.error( + `⚠️ ${affected.length} item(s) running low (≤ ${LOW_STOCK_THRESHOLD} pcs): ${names}${extra}`, + { id: "inventory-low-stock", duration: 6000 }, + ); + } + + // Email delivery → notify the CSR team (sent once per detection) + if (emailEnabled && !emailSentRef.current) { + emailSentRef.current = true; + fetch("/api/admin/alerts/low-inventory", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + threshold: LOW_STOCK_THRESHOLD, + lowItems: lowStockItems.map((r) => ({ name: r.item_name, stock: r.current_stock })), + outItems: outOfStockItems.map((r) => ({ name: r.item_name, stock: 0 })), + }), + }).catch((e) => console.error("Failed to send low-inventory alert email:", e)); + } + }, [loading, alertsEnabled, pushEnabled, emailEnabled, lowStockItems, outOfStockItems]); const categoryOptions = CATEGORY_FILTER_OPTIONS; @@ -1034,7 +1081,7 @@ export default function InventoryPage() { )} {/* Low Stock Warning */} - {!loading && (lowStockItems.length > 0 || outOfStockItems.length > 0) && ( + {!loading && alertsEnabled && (lowStockItems.length > 0 || outOfStockItems.length > 0) && (
diff --git a/Components/admin/Csr/Modals/MessageModal.tsx b/Components/admin/Csr/Modals/MessageModal.tsx index 65ec0495..f9b336d8 100644 --- a/Components/admin/Csr/Modals/MessageModal.tsx +++ b/Components/admin/Csr/Modals/MessageModal.tsx @@ -147,7 +147,7 @@ export default function MessageModal({ right: position.right, }} > -
+
@@ -167,16 +167,6 @@ export default function MessageModal({
-
diff --git a/Components/admin/Csr/Modals/Notification.tsx b/Components/admin/Csr/Modals/Notification.tsx index 1ecbb354..0ae7127f 100644 --- a/Components/admin/Csr/Modals/Notification.tsx +++ b/Components/admin/Csr/Modals/Notification.tsx @@ -1,8 +1,8 @@ "use client"; -import { ReactNode, RefObject, useEffect, useRef, useState } from "react"; +import { ReactNode, RefObject, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { BellRing, CheckCircle2, Clock, Info, X } from "lucide-react"; +import { BellRing, CheckCircle2, Clock, Info } from "lucide-react"; import { useGetNotificationsQuery, useUpdateNotificationsMutation, useMarkAllAsReadMutation, Notification } from "@/redux/api/notificationsApi"; import toast from 'react-hot-toast'; @@ -65,19 +65,9 @@ export default function NotificationModal({ onClose, onViewAll, anchorRef, userI const [updateNotifications] = useUpdateNotificationsMutation(); const [markAllAsRead] = useMarkAllAsReadMutation(); - // Use requestAnimationFrame to avoid cascading renders - useEffect(() => { - const rafId = requestAnimationFrame(() => { - setIsMounted(true); - }); - return () => { - cancelAnimationFrame(rafId); - setIsMounted(false); - }; - }, []); - - useEffect(() => { - if (!isMounted) return; + // Position the panel under the bell BEFORE the first paint, so it doesn't + // flash at a default spot and then jump into place. + useLayoutEffect(() => { function updatePosition() { if (!anchorRef?.current) { setPosition({ top: 96, right: 16 }); @@ -92,6 +82,7 @@ export default function NotificationModal({ onClose, onViewAll, anchorRef, userI } updatePosition(); + setIsMounted(true); window.addEventListener("resize", updatePosition); document.addEventListener("scroll", updatePosition, true); @@ -100,7 +91,7 @@ export default function NotificationModal({ onClose, onViewAll, anchorRef, userI window.removeEventListener("resize", updatePosition); document.removeEventListener("scroll", updatePosition, true); }; - }, [anchorRef, isMounted]); + }, [anchorRef]); useEffect(() => { if (!isMounted) return; @@ -141,7 +132,7 @@ export default function NotificationModal({ onClose, onViewAll, anchorRef, userI right: position.right, }} > -
+
@@ -157,13 +148,6 @@ export default function NotificationModal({ onClose, onViewAll, anchorRef, userI
-
diff --git a/Components/admin/Csr/SettingsPage.tsx b/Components/admin/Csr/SettingsPage.tsx index 1e0a4316..cb16669f 100644 --- a/Components/admin/Csr/SettingsPage.tsx +++ b/Components/admin/Csr/SettingsPage.tsx @@ -4,11 +4,9 @@ import { Bell, ShieldCheck, Palette, - Info, Smartphone, Mail, AlertTriangle, - Fingerprint, Wifi, Lock, Calendar, @@ -33,6 +31,7 @@ import { useEffect, useState } from "react"; import { useTheme } from "next-themes"; import { toast } from "react-hot-toast"; import axios from "axios"; +import { signOut } from "next-auth/react"; const primaryBtn = "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-brand-primary focus:ring-offset-2"; @@ -52,30 +51,27 @@ export default function SettingsPage() { systemAlerts: true, emergencyAlerts: true, maintenanceAlerts: true, - lowInventoryAlerts: false, + lowInventoryAlerts: true, + // Delivery methods + email: true, + push: true, }); const [isLoading, setIsLoading] = useState(false); - const [hasChanges, setHasChanges] = useState(false); + const [isSaving, setIsSaving] = useState(false); // Load settings from backend useEffect(() => { loadSettings(); }, []); - useEffect(() => { - setHasChanges(true); - }, [notificationPrefs]); - const loadSettings = async () => { try { setIsLoading(true); const response = await axios.get('/api/admin/settings/csr'); const data = response.data; - + if (data.notificationPrefs) setNotificationPrefs(data.notificationPrefs); - - setHasChanges(false); } catch (error) { console.error('Failed to load settings:', error); toast.error('Failed to load settings'); @@ -84,42 +80,54 @@ export default function SettingsPage() { } }; - const saveSettings = async () => { + // Auto-save: persist the given preferences to the backend immediately + const persistPrefs = async (prefs: typeof notificationPrefs) => { try { - setIsLoading(true); - await axios.post('/api/admin/settings/csr', { - notificationPrefs, - }); - - setHasChanges(false); - toast.success('Settings saved successfully'); + setIsSaving(true); + await axios.post('/api/admin/settings/csr', { notificationPrefs: prefs }); } catch (error) { console.error('Failed to save settings:', error); - toast.error('Failed to save settings'); + toast.error('Failed to save setting'); } finally { - setIsLoading(false); + setIsSaving(false); } }; - const resetToDefaults = () => { - setNotificationPrefs({ - newBookings: true, - paymentConfirmations: true, - checkInNotifications: true, - checkOutNotifications: true, - cleaningUpdates: true, - deliverableUpdates: true, - guestMessages: true, - systemAlerts: true, - emergencyAlerts: true, - maintenanceAlerts: true, - lowInventoryAlerts: false, - }); - toast.success('Settings reset to defaults'); + const toggleNotification = (key: keyof typeof notificationPrefs) => { + const next = { ...notificationPrefs, [key]: !notificationPrefs[key] }; + setNotificationPrefs(next); + persistPrefs(next); // save instantly — no "Save Changes" button needed }; - const toggleNotification = (key: keyof typeof notificationPrefs) => { - setNotificationPrefs((prev) => ({ ...prev, [key]: !prev[key] })); + // Multi-factor authentication (email OTP) for the logged-in employee + const [mfaEnabled, setMfaEnabled] = useState(false); + const [mfaLoading, setMfaLoading] = useState(false); + + useEffect(() => { + axios + .get('/api/admin/settings/csr/mfa') + .then((res) => setMfaEnabled(Boolean(res.data?.mfaEnabled))) + .catch((err) => console.error('Failed to load MFA status:', err)); + }, []); + + const toggleMfa = async () => { + const next = !mfaEnabled; + setMfaEnabled(next); // optimistic update + setMfaLoading(true); + try { + await axios.post('/api/admin/settings/csr/mfa', { enabled: next }); + toast.success( + next + ? "Two-factor enabled — you'll get an email code at login" + : 'Two-factor disabled', + ); + } catch (error) { + setMfaEnabled(!next); // revert on failure + console.error('Failed to update MFA:', error); + toast.error('Failed to update two-factor setting'); + } finally { + setMfaLoading(false); + } }; return ( @@ -130,22 +138,23 @@ export default function SettingsPage() {

Settings

-
- {hasChanges && ( - +
+ {isLoading ? ( + + + Loading… + + ) : isSaving ? ( + + + Saving… + + ) : ( + + + All changes saved automatically + )} -

@@ -368,10 +377,17 @@ export default function SettingsPage() {

))} @@ -395,44 +411,36 @@ export default function SettingsPage() {

- {[ - { - title: "Last verified login", - meta: "Today, 10:42 AM · QC office IP", - icon: , - action: "Review activity", - }, - { - title: "Multi-factor enforcement", - meta: "Required for all CSR roles", - icon: , - action: "Manage MFA", - }, - { - title: "Recovery contacts", - meta: "csr-ops@staycation.ph · 2 fallback numbers", - icon: , - action: "Update contacts", - }, - ].map((item) => ( -
-
-
{item.icon}
-
-

- {item.title} -

-

{item.meta}

-
+ {/* Two-factor authentication — real, working toggle */} +
+
+
+ +
+
+

+ Two-factor authentication +

+

+ {mfaEnabled + ? "Enabled — a verification code is emailed at every login" + : "Disabled — sign in with just your password"} +

-
- ))} + +
@@ -450,8 +458,8 @@ export default function SettingsPage() { Session management

- Log out idle devices or regenerate API keys if an integration was - compromised. + Your session signs out automatically when idle. You can also sign out + of this device manually below.

@@ -459,21 +467,28 @@ export default function SettingsPage() {

Idle device timeout

-

Sessions inactive for 45 minutes will be auto-signed out.

+

Sessions inactive for 30 minutes are automatically signed out.

- Force sign-out on all devices + Sign out of this device

- Useful if an agent misplaced a laptop or phone. + Ends your current session and returns you to the login page.

-
diff --git a/Components/admin/Owners/Modals/AdminLogin.tsx b/Components/admin/Owners/Modals/AdminLogin.tsx index 898f1e95..a210807b 100644 --- a/Components/admin/Owners/Modals/AdminLogin.tsx +++ b/Components/admin/Owners/Modals/AdminLogin.tsx @@ -50,6 +50,11 @@ const AdminLogin = () => { const [otpEmail, setOtpEmail] = useState(""); const [otpPassword, setOtpPassword] = useState(""); + // MFA (email OTP) step + const [showMfa, setShowMfa] = useState(false); + const [mfaCode, setMfaCode] = useState(""); + const [mfaSubmitting, setMfaSubmitting] = useState(false); + const [formData, setFormData] = useState({ email: "", password: "", @@ -234,6 +239,15 @@ const AdminLogin = () => { }); if (result?.error) { + // 🔐 MFA required: a code was emailed — show the code prompt + if (result.error.includes("MFA_REQUIRED")) { + setShowMfa(true); + setMfaCode(""); + setFormData((prev) => ({ ...prev, isLoading: false, error: null })); + toast.success("We emailed you a 6-digit verification code."); + return; + } + // Check if error indicates OTP is required if (result.error.includes("Account locked due to multiple failed attempts")) { setOtpEmail(formData.email); @@ -339,6 +353,59 @@ const AdminLogin = () => { } } + const redirectByRole = (role?: string) => { + switch (role?.toLowerCase()) { + case "csr": router.push("/admin/csr"); break; + case "owner": router.push("/admin/owners"); break; + case "partner": router.push("/admin/partners"); break; + case "cleaner": router.push("/admin/cleaners"); break; + default: router.push("/admin/owners"); + } + }; + + const handleMfaVerify = async () => { + if (mfaCode.trim().length !== 6) { + toast.error("Please enter the 6-digit code"); + return; + } + setMfaSubmitting(true); + try { + const result = await signIn("credentials", { + email: formData.email, + password: formData.password, + mfaCode: mfaCode.trim(), + redirect: false, + }); + + if (result?.error) { + toast.error( + result.error.includes("Invalid or expired") + ? "Invalid or expired code. Please try again." + : result.error || "Verification failed", + ); + setMfaSubmitting(false); + return; + } + + if (result?.ok) { + const { data: session } = await axios.get("/api/auth/session"); + toast.success(`Welcome back, ${session?.user?.name || ""}!`); + redirectByRole(session?.user?.role); + } + } catch (error) { + console.error("MFA verify error:", error); + toast.error("Verification failed. Please try again."); + setMfaSubmitting(false); + } + }; + + const handleBackFromMfa = () => { + setShowMfa(false); + setMfaCode(""); + // Turnstile token was consumed on the first attempt — reload to get a fresh one + window.location.reload(); + }; + return ( <> {/* Navbar */} @@ -357,6 +424,63 @@ const AdminLogin = () => { onBack={handleBackToLogin} onSuccess={handleOtpSuccess} /> + ) : showMfa ? ( + /* MFA Code Card */ +
+
+
+ +
+

+ Two-Factor Verification +

+

+ Enter the 6-digit code we emailed to +
+ {formData.email} +

+
+ + setMfaCode(e.target.value.replace(/\D/g, ""))} + onKeyDown={(e) => { + if (e.key === "Enter") handleMfaVerify(); + }} + placeholder="000000" + autoFocus + className="w-full text-center tracking-[0.5em] text-2xl font-semibold py-3 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary focus:border-transparent" + /> + + + + + +

+ The code expires in 10 minutes. Check your spam folder if you don't see it. +

+
) : ( /* Login Card */
diff --git a/app/api/admin/alerts/low-inventory/route.ts b/app/api/admin/alerts/low-inventory/route.ts new file mode 100644 index 00000000..e880a8c1 --- /dev/null +++ b/app/api/admin/alerts/low-inventory/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/backend/utils/requireAdmin"; +import { sendLowInventoryAlertEmail, LowStockEmailItem } from "@/backend/utils/mailer"; + +export async function POST(request: NextRequest) { + const guard = await requireAdmin(); + if (!guard.ok) return guard.response; + + try { + const body = await request.json(); + const lowItems: LowStockEmailItem[] = Array.isArray(body?.lowItems) ? body.lowItems : []; + const outItems: LowStockEmailItem[] = Array.isArray(body?.outItems) ? body.outItems : []; + const threshold: number = typeof body?.threshold === "number" ? body.threshold : 15; + + if (lowItems.length === 0 && outItems.length === 0) { + return NextResponse.json({ message: "No low-stock items; nothing to send." }); + } + + // Recipient: configurable via env, falls back to the sending account + const recipient = process.env.CSR_ALERT_EMAIL || process.env.EMAIL_USER; + if (!recipient) { + return NextResponse.json( + { error: "No alert recipient configured (set CSR_ALERT_EMAIL or EMAIL_USER)." }, + { status: 500 }, + ); + } + + const sent = await sendLowInventoryAlertEmail(recipient, lowItems, outItems, threshold); + if (!sent) { + return NextResponse.json({ error: "Failed to send alert email." }, { status: 500 }); + } + + return NextResponse.json({ message: "Low inventory alert email sent.", recipient }); + } catch (error) { + console.error("Error sending low inventory alert:", error); + return NextResponse.json({ error: "Failed to process alert." }, { status: 500 }); + } +} diff --git a/app/api/admin/settings/csr/mfa/route.ts b/app/api/admin/settings/csr/mfa/route.ts new file mode 100644 index 00000000..b5b6550b --- /dev/null +++ b/app/api/admin/settings/csr/mfa/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/backend/utils/requireAdmin"; +import pool from "@/backend/config/db"; + +// Ensure the mfa_enabled column exists (self-healing if the migration wasn't run). +async function ensureMfaColumn() { + await pool.query( + "ALTER TABLE employees ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN NOT NULL DEFAULT false", + ); +} + +// GET — return the current employee's MFA status +export async function GET() { + const guard = await requireAdmin(); + if (!guard.ok) return guard.response; + + const email = guard.session.user.email; + try { + const result = await pool.query( + "SELECT mfa_enabled FROM employees WHERE email = $1", + [email], + ); + const mfaEnabled = result.rows[0]?.mfa_enabled ?? false; + return NextResponse.json({ mfaEnabled }); + } catch (error) { + // Column likely missing (migration not run) — degrade gracefully so the + // Settings page still loads. Toggling MFA on will create the column. + console.error("MFA status check failed (treating as disabled):", error); + return NextResponse.json({ mfaEnabled: false }); + } +} + +// POST — enable/disable MFA for the current employee +export async function POST(request: NextRequest) { + const guard = await requireAdmin(); + if (!guard.ok) return guard.response; + + const email = guard.session.user.email; + try { + const body = await request.json(); + const enabled = Boolean(body?.enabled); + + // Make sure the column exists before we write to it. + await ensureMfaColumn(); + + const result = await pool.query( + "UPDATE employees SET mfa_enabled = $1, updated_at = NOW() WHERE email = $2 RETURNING mfa_enabled", + [enabled, email], + ); + + if (result.rows.length === 0) { + return NextResponse.json({ error: "Employee not found" }, { status: 404 }); + } + + return NextResponse.json({ mfaEnabled: result.rows[0].mfa_enabled }); + } catch (error) { + console.error("Error updating MFA status:", error); + return NextResponse.json({ error: "Failed to update MFA status" }, { status: 500 }); + } +} diff --git a/app/api/admin/settings/csr/route.ts b/app/api/admin/settings/csr/route.ts index a16a3877..38d43b03 100644 --- a/app/api/admin/settings/csr/route.ts +++ b/app/api/admin/settings/csr/route.ts @@ -14,7 +14,10 @@ let csrSettings: any = { systemAlerts: true, emergencyAlerts: true, maintenanceAlerts: true, - lowInventoryAlerts: false, + lowInventoryAlerts: true, + // Delivery methods + email: true, + push: true, }, }; diff --git a/backend/models/employee.sql b/backend/models/employee.sql index 82b6e52a..c0432db4 100644 --- a/backend/models/employee.sql +++ b/backend/models/employee.sql @@ -20,6 +20,7 @@ CREATE TABLE employees ( user_agent TEXT DEFAULT NULL, login_attempts INT DEFAULT 0, last_login TIMESTAMPTZ NOT NULL DEFAULT NOW(), + mfa_enabled BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) diff --git a/backend/models/migrations/add_employee_mfa.sql b/backend/models/migrations/add_employee_mfa.sql new file mode 100644 index 00000000..cc272734 --- /dev/null +++ b/backend/models/migrations/add_employee_mfa.sql @@ -0,0 +1,9 @@ +-- Migration: add email-OTP MFA support for employees (CSR/admin/Owner/Cleaner) +-- Safe to run multiple times (IF NOT EXISTS). +-- Run this against your database before using the MFA toggle in CSR Settings. + +ALTER TABLE employees + ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN NOT NULL DEFAULT false; + +-- MFA login codes reuse the existing otp_verification table with +-- otp_type = 'MFA_LOGIN' (no schema change needed there). diff --git a/backend/utils/mailer.ts b/backend/utils/mailer.ts index 6dc6e5f2..23cb1df8 100644 --- a/backend/utils/mailer.ts +++ b/backend/utils/mailer.ts @@ -1061,6 +1061,102 @@ export async function sendEmployeeWelcomeEmail( } } +// --------------------------------------------------------------------------- +// Low Inventory Alert Email +// --------------------------------------------------------------------------- +export interface LowStockEmailItem { + name: string; + stock: number; +} + +export function getLowInventoryAlertEmailTemplate( + lowItems: LowStockEmailItem[], + outItems: LowStockEmailItem[], + threshold: number, +): string { + const date = new Date().toLocaleString("en-PH", { timeZone: "Asia/Manila" }); + const total = lowItems.length + outItems.length; + + const row = (label: string, value: string, color: string) => ` +
${label}${value}
+ + + + + + + ${outRows}${lowRows} +
ItemStatus
+

+ Please review the Inventory page and restock these items soon to avoid running out. +

+
+
+ © ${new Date().getFullYear()} Staycation Haven · Automated inventory alert +
+
+ + `; +} + +// Send a low-inventory alert email to the CSR team +export async function sendLowInventoryAlertEmail( + recipient: string, + lowItems: LowStockEmailItem[], + outItems: LowStockEmailItem[], + threshold: number, +): Promise { + try { + const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASSWORD, + }, + }); + + const total = lowItems.length + outItems.length; + const htmlContent = getLowInventoryAlertEmailTemplate(lowItems, outItems, threshold); + + const mailOptions = { + from: `"Staycation Haven" <${process.env.EMAIL_USER}>`, + to: recipient, + subject: `⚠️ Low Inventory Alert - ${total} item(s) need restocking`, + html: htmlContent, + }; + + await transporter.sendMail(mailOptions); + console.log(`Low inventory alert email sent to ${recipient}`); + return true; + } catch (error) { + console.error("Error sending low inventory alert email:", error); + return false; + } +} + // Send partner welcome email using the same setup as booking emails export async function sendPartnerWelcomeEmail( email: string, diff --git a/lib/auth.ts b/lib/auth.ts index 8f04eea1..16dfe793 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -46,6 +46,7 @@ export const authOptions: NextAuthOptions = { password: { label: "Password", type: "password" }, turnstileToken: { label: "Turnstile Token", type: "text", optional: true }, isOtpLogin: { label: "OTP Login", type: "text", optional: true }, + mfaCode: { label: "MFA Code", type: "text", optional: true }, }, async authorize(credentials, req) { try { @@ -90,13 +91,14 @@ export const authOptions: NextAuthOptions = { // For employees, require turnstile token verification // 🔐 Require Turnstile ONLY if this is NOT an OTP-based auto login - if (!credentials?.turnstileToken && !credentials?.isOtpLogin) { + if (!credentials?.turnstileToken && !credentials?.isOtpLogin && !credentials?.mfaCode) { console.log("❌ Missing turnstile token for employee"); throw new Error("Email, password, and security verification are required"); } - // Verify Turnstile token for employees - if (!credentials?.isOtpLogin) { + // Verify Turnstile token for employees (skipped on the MFA 2nd step, + // since the token was already consumed during the 1st step) + if (!credentials?.isOtpLogin && !credentials?.mfaCode) { const isValidTurnstile = await verifyTurnstileToken(credentials.turnstileToken || ""); if (!isValidTurnstile) { console.log("❌ Invalid Turnstile token"); @@ -183,6 +185,82 @@ export const authOptions: NextAuthOptions = { console.log("✅ Password valid! Employee login successful"); + // 🔐 Email-OTP MFA gate (employees only, opt-in via CSR Settings). + // Runs only after the password is already valid. Looked up defensively + // so a missing mfa_enabled column (migration not yet run) never blocks login. + let mfaEnabled = false; + try { + const mfaRow = await pool.query( + "SELECT mfa_enabled FROM employees WHERE id = $1", + [user.id] + ); + mfaEnabled = mfaRow.rows[0]?.mfa_enabled === true; + } catch { + mfaEnabled = false; // column doesn't exist yet → treat MFA as off + } + + if (mfaEnabled) { + const providedCode = (credentials?.mfaCode || "").trim(); + + if (!providedCode) { + // Step 1 — password ok, but no MFA code yet: email a one-time code + // and signal the client to prompt for it. + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); + + await pool.query( + `DELETE FROM otp_verification WHERE email = $1 AND otp_type = 'MFA_LOGIN'`, + [credentials.email] + ); + await pool.query( + `INSERT INTO otp_verification + (email, otp_code, otp_type, expires_at, ip_address, user_agent, created_at) + VALUES ($1, $2, 'MFA_LOGIN', $3, $4, $5, NOW())`, + [ + credentials.email, + otp, + expiresAt, + ipAddress !== 'unknown' ? ipAddress : null, + userAgent !== 'unknown' ? userAgent : null, + ] + ); + + try { + await sendOtpEmail({ + email: credentials.email, + otp, + type: "MFA_LOGIN", + userName: `${user.first_name} ${user.last_name}`, + }); + } catch (emailError) { + console.error("❌ Failed to send MFA code email:", emailError); + throw new Error("Could not send your verification code. Please try again."); + } + + // Special signal — the login UI shows the code prompt on this error. + throw new Error("MFA_REQUIRED"); + } + + // Step 2 — verify the submitted code. + const mfaCheck = await pool.query( + `SELECT id FROM otp_verification + WHERE email = $1 AND otp_code = $2 AND otp_type = 'MFA_LOGIN' AND expires_at > NOW() + ORDER BY created_at DESC LIMIT 1`, + [credentials.email, providedCode] + ); + + if (mfaCheck.rows.length === 0) { + throw new Error("Invalid or expired verification code"); + } + + // One-time use — clear the code(s) once verified. + await pool.query( + `DELETE FROM otp_verification WHERE email = $1 AND otp_type = 'MFA_LOGIN'`, + [credentials.email] + ); + console.log("✅ MFA code verified for employee:", user.email); + } + // Reset login attempts on successful login try { await pool.query( From c45675f4d1af6eedcf6fdad15835295a00316d69 Mon Sep 17 00:00:00 2001 From: roje1030 Date: Thu, 11 Jun 2026 15:34:09 +0800 Subject: [PATCH 3/5] JUNE 11, 2026 --- .claude/settings.local.json | 4 +- .../admin/Cleaners/CleanersDashboard.tsx | 82 +-- Components/admin/Cleaners/MessagesPage.tsx | 311 ++++++++--- Components/admin/Cleaners/MySchedulePage.tsx | 510 +++++++++++------- Components/admin/Cleaners/ReportIssuePage.tsx | 20 +- Components/admin/Cleaners/translations.ts | 38 ++ Components/admin/Csr/MessagePage.tsx | 179 ++++-- app/api/messages/upload/route.ts | 59 ++ 8 files changed, 812 insertions(+), 391 deletions(-) create mode 100644 app/api/messages/upload/route.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 80815534..ebeade8c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,9 @@ "allow": [ "Bash(git mv:*)", "Bash(git pull *)", - "Bash(node -e ' *)" + "Bash(node -e ' *)", + "Bash(curl -s -o /tmp/preview2.html -w \"%{http_code}\" http://localhost:3000/preview-next-job --max-time 90)", + "Read(//tmp/**)" ] } } diff --git a/Components/admin/Cleaners/CleanersDashboard.tsx b/Components/admin/Cleaners/CleanersDashboard.tsx index a8851235..cd4f3676 100644 --- a/Components/admin/Cleaners/CleanersDashboard.tsx +++ b/Components/admin/Cleaners/CleanersDashboard.tsx @@ -483,78 +483,12 @@ useEffect(() => { ); })} - - {/* User Profile Section */} -
- {sidebar && ( -
- {isLoading ? ( -
-
-
-
-
-
-
- ) : ( -
- {employee?.profile_image_url ? ( -
- {cleanerData.name} -
- ) : ( -
- {cleanerData.name.charAt(0)} -
- )} -
-

- {cleanerData.name} -

-

- {cleanerData.role} -

-
-
- )} -
- -
-
- )} - {!sidebar && ( -
- -
- )} -
{/* MAIN CONTENT */} -
+
{/* HEADER */} -
+
{/* Mobile Menu Button */} {profileDropdownOpen && ( -
+
{/* User Info Header */}
@@ -697,12 +631,12 @@ useEffect(() => { alt={cleanerData.name} width={40} height={40} - className="w-10 h-10 rounded-full object-cover ring-2 ring-gray-200 dark:ring-gray-600" + className="w-10 h-10 rounded-full object-cover ring-2 ring-brand-primary" key={`${employee.profile_image_url}-${employee.updated_at || ''}`} /> ) : ( -
- +
+
)}
@@ -816,7 +750,7 @@ useEffect(() => { )} {/* PAGE CONTENT */} -
+
{renderPage()}
diff --git a/Components/admin/Cleaners/MessagesPage.tsx b/Components/admin/Cleaners/MessagesPage.tsx index 9dd4be37..9e357339 100644 --- a/Components/admin/Cleaners/MessagesPage.tsx +++ b/Components/admin/Cleaners/MessagesPage.tsx @@ -11,6 +11,8 @@ import { Loader2, ArrowLeft, ZoomIn, + Smile, + Play, } from "lucide-react"; import Image from "next/image"; import { @@ -56,6 +58,7 @@ interface Message { sender_id: string; sender_name?: string; message_text: string; + image_url?: string | null; created_at: string; } @@ -100,13 +103,52 @@ const getActiveStatus = (lastMessageTime: string | undefined, type: string) => { return { isActive: false, statusText: "Offline" }; }; +// Detect whether a stored message is a media attachment. Newer attachments are +// Cloudinary URLs; older images may still be inline base64 data URLs. +const getMediaType = (text: string): "image" | "video" | "text" => { + if (!text) return "text"; + if (text.startsWith("data:image")) return "image"; + if (text.startsWith("data:video")) return "video"; + if (/res\.cloudinary\.com/i.test(text)) { + return /\/video\/upload\//i.test(text) ? "video" : "image"; + } + return "text"; +}; + +// Resolve a message's attachment from either the image_url column (used by the +// CSR side) or an inline message_text URL/base64 (used by the cleaner side). +const getMessageMedia = ( + m: { image_url?: string | null; message_text?: string }, +): { src: string; type: "image" | "video" } | null => { + if (m.image_url) { + return { src: m.image_url, type: /\/video\/upload\//i.test(m.image_url) ? "video" : "image" }; + } + const t = getMediaType(m.message_text || ""); + if (t === "text") return null; + return { src: m.message_text || "", type: t }; +}; + +const EMOJIS = [ + "😀", "😄", "😁", "😂", "🙂", "😉", "😊", "😍", "😘", "🤗", + "🤔", "😴", "😎", "😢", "😭", "😡", "👍", "👎", "👏", "🙏", + "💪", "👋", "🔥", "✨", "🎉", "❤️", "✅", "❌", "⚠️", "🧹", +]; + const Skeleton = ({ className }: { className: string }) => (
); // ─── Lightbox ──────────────────────────────────────────────────────────────── -function Lightbox({ src, onClose }: { src: string; onClose: () => void }) { +function Lightbox({ + src, + type, + onClose, +}: { + src: string; + type: "image" | "video"; + onClose: () => void; +}) { // Close on Escape key useEffect(() => { const handler = (e: KeyboardEvent) => e.key === "Escape" && onClose(); @@ -129,17 +171,26 @@ function Lightbox({ src, onClose }: { src: string; onClose: () => void }) { - {/* Image — stop propagation so clicking image doesn't close */} + {/* Media — stop propagation so clicking it doesn't close */}
e.stopPropagation()} > - {/* eslint-disable-next-line @next/next/no-img-element */} - Attachment + {type === "video" ? ( +
); @@ -153,8 +204,9 @@ export default function MessagesPage({ onClose, initialConversationId }: Message const [search, setSearch] = useState(""); const [draft, setDraft] = useState(""); - const [attachedImage, setAttachedImage] = useState(null); // base64 preview - const [lightboxSrc, setLightboxSrc] = useState(null); + const [attachedMedia, setAttachedMedia] = useState<{ url: string; type: "image" | "video" } | null>(null); + const [isUploadingMedia, setIsUploadingMedia] = useState(false); + const [lightbox, setLightbox] = useState<{ src: string; type: "image" | "video" } | null>(null); const [isNewMessageModalOpen, setIsNewMessageModalOpen] = useState(false); const [showMobileChat, setShowMobileChat] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false); @@ -348,54 +400,71 @@ export default function MessagesPage({ onClose, initialConversationId }: Message }); }, [search, conversations, roleFilter, employeeRoleById, userId]); - // ── image attachment ─────────────────────────────────────────────────────── + // ── image / video attachment ─────────────────────────────────────────────── const handleImageIconClick = () => fileInputRef.current?.click(); - const handleFileChange = (e: React.ChangeEvent) => { + const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; + // Reset input so the same file can be re-selected later + e.target.value = ""; if (!file) return; - if (!file.type.startsWith("image/")) { - toast.error("Only image files are supported."); + const isImage = file.type.startsWith("image/"); + const isVideo = file.type.startsWith("video/"); + if (!isImage && !isVideo) { + toast.error("Only image and video files are supported."); return; } - if (file.size > 5 * 1024 * 1024) { - toast.error("Image must be smaller than 5 MB."); + const maxBytes = isVideo ? 50 * 1024 * 1024 : 10 * 1024 * 1024; + if (file.size > maxBytes) { + toast.error(`${isVideo ? "Video" : "Image"} must be smaller than ${isVideo ? 50 : 10} MB.`); return; } - const reader = new FileReader(); - reader.onload = () => setAttachedImage(reader.result as string); - reader.readAsDataURL(file); - - // Reset input so the same file can be re-selected - e.target.value = ""; + // Upload to Cloudinary; the message stores just the URL (keeps the 3s poll light). + setIsUploadingMedia(true); + try { + const formData = new FormData(); + formData.append("file", file); + const res = await fetch("/api/messages/upload", { method: "POST", body: formData }); + const data = await res.json(); + if (!res.ok || !data?.success || !data?.url) { + throw new Error(data?.error || "Upload failed"); + } + setAttachedMedia({ url: data.url, type: isVideo ? "video" : "image" }); + } catch (err) { + console.error("Media upload failed:", err); + toast.error(err instanceof Error ? err.message : "Failed to upload file."); + } finally { + setIsUploadingMedia(false); + } }; - const removeAttachedImage = () => setAttachedImage(null); + const removeAttachedMedia = () => setAttachedMedia(null); // ── send ─────────────────────────────────────────────────────────────────── const handleSendMessage = async () => { const text = draft.trim(); - if (!text && !attachedImage) return; + if (!text && !attachedMedia) return; if (!activeId || !userId) return; + if (isUploadingMedia) return; try { await sendMessage({ conversation_id: activeId, sender_id: userId, sender_name: session?.user?.name || "Cleaner", - message_text: attachedImage || text, + message_text: attachedMedia?.url || text, }).unwrap(); setDraft(""); - setAttachedImage(null); + setAttachedMedia(null); refetchMessages(); refetchConversations(); - + toast.success("Message sent!"); } catch (error: unknown) { console.error("Failed to send message:", error); @@ -476,15 +545,15 @@ export default function MessagesPage({ onClose, initialConversationId }: Message return ( <> {/* Lightbox */} - {lightboxSrc && ( - setLightboxSrc(null)} /> + {lightbox && ( + setLightbox(null)} /> )} {/* Hidden file input */} @@ -721,32 +790,81 @@ export default function MessagesPage({ onClose, initialConversationId }: Message )} - {m.message_text.startsWith("data:image") ? ( -
setLightboxSrc(m.message_text)} - > - {/* eslint-disable-next-line @next/next/no-img-element */} - Attachment -
- + {(() => { + const media = getMessageMedia(m); + // Caption: text alongside an attachment, but not the media URL itself. + const caption = + media && m.message_text && m.message_text !== media.src + ? m.message_text + : null; + const captionBubble = caption && ( +
+ {caption}
-
- ) : ( -
- {m.message_text} -
- )} + ); + if (media?.type === "image") { + return ( + <> +
setLightbox({ src: media.src, type: "image" })} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + Attachment +
+ +
+
+ {captionBubble} + + ); + } + if (media?.type === "video") { + return ( + <> +
setLightbox({ src: media.src, type: "video" })} + > +
+ {captionBubble} + + ); + } + return ( +
+ {m.message_text} +
+ ); + })()} {memoizedFormatMessageTime(m.created_at)}
@@ -763,22 +881,42 @@ export default function MessagesPage({ onClose, initialConversationId }: Message {/* Input area */}
- {/* Image preview strip */} - {attachedImage && ( + {/* Uploading indicator */} + {isUploadingMedia && ( +
+ + Uploading… +
+ )} + + {/* Attachment preview strip (image or video) */} + {attachedMedia && !isUploadingMedia && (
- {/* eslint-disable-next-line @next/next/no-img-element */} - Attachment preview setLightboxSrc(attachedImage)} - /> + {attachedMedia.type === "video" ? ( +
setLightbox({ src: attachedMedia.url, type: "video" })} + > +
+ ) : ( + /* eslint-disable-next-line @next/next/no-img-element */ + Attachment preview setLightbox({ src: attachedMedia.url, type: "image" })} + /> + )} @@ -796,16 +934,47 @@ export default function MessagesPage({ onClose, initialConversationId }: Message - {/* Image attach button */} + {/* Photo / video attach button */} + {/* Emoji picker */} +
+ + {showEmojiPicker && ( +
+ {EMOJIS.map((emoji) => ( + + ))} +
+ )} +
+
diff --git a/Components/admin/Cleaners/MySchedulePage.tsx b/Components/admin/Cleaners/MySchedulePage.tsx index 5883a4b0..cd166f8f 100644 --- a/Components/admin/Cleaners/MySchedulePage.tsx +++ b/Components/admin/Cleaners/MySchedulePage.tsx @@ -21,8 +21,9 @@ import { User, Phone, Pencil, + ArrowRight, } from "lucide-react"; -import { useState, useMemo, useRef } from "react"; +import { useState, useMemo, useRef, useEffect } from "react"; import { createPortal } from "react-dom"; import { useSession } from "next-auth/react"; import { useGetCleaningTasksQuery } from "@/redux/api/cleanersApi"; @@ -464,6 +465,234 @@ export default function MySchedulePage({ onNavigate = () => {}, onStartCleaning, }) : []; + // ── Next upcoming cleaning job (for the hero card + auto-select) ───────────── + const nextCleaningJob = useMemo(() => { + const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + return ( + assignments + .filter((a: any) => { + if (!a.check_out_date) return false; + const isDone = a.cleaning_status === "cleaned" || a.cleaning_status === "inspected"; + if (isDone) return false; + return toLocalMidnight(a.check_out_date) >= todayMidnight; + }) + .sort( + (a: any, b: any) => + toLocalMidnight(a.check_out_date).getTime() - + toLocalMidnight(b.check_out_date).getTime(), + )[0] || null + ); + }, [assignments, today]); + + // Friendly relative-day label: Today / Tomorrow / weekday + date + const dayLabel = (d: Date) => { + const tm = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const tomorrow = new Date(tm); + tomorrow.setDate(tm.getDate() + 1); + if (isSameDay(d, tm)) return t.today; + if (isSameDay(d, tomorrow)) return t.tomorrow; + return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }); + }; + + // On first load, if today has no task, jump the calendar/panel to the nearest + // upcoming job so a non-techy cleaner never sees a misleading empty "today". + const didAutoSelect = useRef(false); + useEffect(() => { + if (didAutoSelect.current || isLoading || assignments.length === 0) return; + didAutoSelect.current = true; + + const todayHasTask = assignments.some( + (a: any) => + (a.check_out_date && isSameDay(toLocalMidnight(a.check_out_date), today)) || + (a.check_in_date && isSameDay(toLocalMidnight(a.check_in_date), today)), + ); + if (todayHasTask) return; + + if (nextCleaningJob?.check_out_date) { + const d = toLocalMidnight(nextCleaningJob.check_out_date); + setSelectedDate(d); + setCalMonth(new Date(d.getFullYear(), d.getMonth(), 1)); + } + }, [isLoading, assignments, today, nextCleaningJob]); + + // Open the cleaning checklist for a given assignment (same logic as detail cards). + const startCleaningFor = (a: any) => { + const havenId = a.haven_id ?? a.booking_uuid; + if (onStartCleaning && havenId) { + onStartCleaning(String(havenId), a.booking_uuid ? String(a.booking_uuid) : undefined); + } else { + onNavigate("cleaning-checklist"); + } + }; + + // ── Reusable job cards (shared by the calendar detail panel and the list view) ── + const renderDepositCard = (a: any) => { + // 'pending_verification' = cleaner submitted, awaiting CSR review. + // 'held' / 'paid' = CSR confirmed. All three mean "already collected". + const depositCollected = + a.deposit_status === "held" || + a.deposit_status === "paid" || + a.deposit_status === "pending_verification" || + collectedDeposits.has(a.cleaning_id); + const secDepAmt = Number(a.security_deposit) > 0 ? Number(a.security_deposit) : 1000; + const remBalAmt = Number(a.remaining_balance) > 0 ? Number(a.remaining_balance) : 0; + const depositAmount = new Intl.NumberFormat("en-PH", { style: "currency", currency: "PHP" }).format(secDepAmt + remBalAmt); + const isCollecting = collectingDepositId === a.cleaning_id; + + return ( +
+
+
+
+ + {t.checkInToday} +
+

+ {a.haven ?? a.room_name ?? "—"} +

+

+ {a.guest_first_name} {a.guest_last_name} +

+
+ {depositCollected ? ( + + {t.collected} + + ) : ( + + {t.pending} + + )} +
+ +
+ + + {t.checkInLabel} {a.check_in_time ? formatTime(a.check_in_time) : "—"} + + + + {depositAmount} + +
+ + {depositCollected ? ( +
+
+ + {t.depositCollected} +
+ +
+ ) : ( + + )} +
+ ); + }; + + const renderCleaningCard = (a: any) => { + const todayMidnight = new Date(); todayMidnight.setHours(0, 0, 0, 0); + const checkoutDate = a.check_out_date ? toLocalMidnight(a.check_out_date) : null; + const isUpcoming = checkoutDate ? checkoutDate > todayMidnight : false; + const isDone = a.cleaning_status === "cleaned" || a.cleaning_status === "inspected"; + const effectiveBadgeStatus = isDone ? a.cleaning_status + : isUpcoming ? "upcoming" + : a.cleaning_status === "in-progress" ? "in-progress" + : "ready"; + const badge = getStatusBadge(effectiveBadgeStatus); + const isActionable = !isDone && !isUpcoming; + + return ( +
+
+

+ {a.haven ?? a.room_name ?? "—"} +

+ + {badge.label} + +
+ +
+ {a.location && ( +
+ + {a.location} +
+ )} +
+ + + {t.checkoutLabel} {formatDate(a.check_out_date)} + {a.check_out_time ? ` · ${formatTime(a.check_out_time)}` : ""} + +
+ {(a.cleaning_time_in || a.cleaning_time_out) && ( +
+ {t.inLabel} {formatTime(a.cleaning_time_in)} + {t.outLabel} {formatTime(a.cleaning_time_out)} +
+ )} +
+ +
+ + + {isActionable && ( + + )} + + {isUpcoming && ( + + )} + + {isDone && ( +
+ + {t.done} +
+ )} +
+
+ ); + }; + const handleTotalTasksClick = () => { const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()); // Find the nearest assignment date that is ready or upcoming @@ -499,33 +728,110 @@ export default function MySchedulePage({ onNavigate = () => {}, onStartCleaning,

+ {/* Next job hero — the one thing a cleaner needs to see first */} + {isLoading ? ( +
+ ) : nextCleaningJob ? ( + (() => { + const job = nextCleaningJob; + const checkoutDate = toLocalMidnight(job.check_out_date); + const tm = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const isUpcoming = checkoutDate > tm; + const unitName = job.haven ?? job.room_name ?? "—"; + return ( +
+
+
+
+ + {t.nextJobTitle} + + + {dayLabel(checkoutDate)} + +
+

{unitName}

+ {job.location && ( +

+ + {job.location} +

+ )} +

+ + {isUpcoming ? ( + + {t.checkoutLabel} {formatDate(job.check_out_date)} + {job.check_out_time ? ` · ${formatTime(job.check_out_time)}` : ""} + + ) : ( + {t.readyNow} + )} +

+
+
+ + +
+
+
+ ); + })() + ) : ( +
+ +
+

{t.nextJobNone}

+

{t.nextJobNoneSub}

+
+
+ )} + {/* Stats row */} -
+
{statsCards.map(({ label, value, color, icon: Icon, sub, onClick }) => { const inner = ( -
-
-

{label}

-

{isLoading ? "…" : value}

+
+
+

{label}

+
+ {isLoading ? ( +
+ ) : ( + value + )} +
{sub && !isLoading && ( -

{sub}

+
{sub}
)}
- +
); return onClick ? ( ) : (
{inner}
@@ -778,189 +1084,11 @@ export default function MySchedulePage({ onNavigate = () => {}, onStartCleaning, const isCheckinDay = a.check_in_date && isSameDay(toLocalMidnight(a.check_in_date), selectedDate); const isCheckoutDay = a.check_out_date && isSameDay(toLocalMidnight(a.check_out_date), selectedDate); - // ── Check-in day: show deposit collection card ────────────── + // Deposit collection (guest check-in) vs cleaning (checkout) if (isCheckinDay && !isCheckoutDay) { - // 'pending_verification' = cleaner submitted, awaiting CSR review. - // 'held' / 'paid' = CSR confirmed. All three mean "already collected" - // from the cleaner's perspective, so the Collect button must hide. - const depositCollected = - a.deposit_status === "held" || - a.deposit_status === "paid" || - a.deposit_status === "pending_verification" || - collectedDeposits.has(a.cleaning_id); - const secDepAmt = Number(a.security_deposit) > 0 ? Number(a.security_deposit) : 1000; - const remBalAmt = Number(a.remaining_balance) > 0 ? Number(a.remaining_balance) : 0; - const depositAmount = new Intl.NumberFormat("en-PH", { style: "currency", currency: "PHP" }).format(secDepAmt + remBalAmt); - const isCollecting = collectingDepositId === a.cleaning_id; - - return ( -
-
-
-
- - {t.checkInToday} -
-

- {a.haven ?? a.room_name ?? "—"} -

-

- {a.guest_first_name} {a.guest_last_name} -

-
- {depositCollected ? ( - - {t.collected} - - ) : ( - - {t.pending} - - )} -
- -
- - - {t.checkInLabel} {a.check_in_time ? formatTime(a.check_in_time) : "—"} - - - - {depositAmount} - -
- - {depositCollected ? ( -
-
- - {t.depositCollected} -
- -
- ) : ( - - )} -
- ); + return renderDepositCard(a); } - - // ── Check-out day: show cleaning task card ────────────────── - const todayMidnight = new Date(); todayMidnight.setHours(0,0,0,0); - const checkoutDate = a.check_out_date ? toLocalMidnight(a.check_out_date) : null; - const isUpcoming = checkoutDate ? checkoutDate > todayMidnight : false; - const isDone = a.cleaning_status === "cleaned" || a.cleaning_status === "inspected"; - const effectiveBadgeStatus = isDone ? a.cleaning_status - : isUpcoming ? "upcoming" - : a.cleaning_status === "in-progress" ? "in-progress" - : "ready"; - const badge = getStatusBadge(effectiveBadgeStatus); - const isActionable = !isDone && !isUpcoming; - - return ( -
-
-

- {a.haven ?? a.room_name ?? "—"} -

- - {badge.label} - -
- -
- {a.location && ( -
- - {a.location} -
- )} -
- - - {t.checkoutLabel} {formatDate(a.check_out_date)} - {a.check_out_time ? ` · ${formatTime(a.check_out_time)}` : ""} - -
- {(a.cleaning_time_in || a.cleaning_time_out) && ( -
- {t.inLabel} {formatTime(a.cleaning_time_in)} - {t.outLabel} {formatTime(a.cleaning_time_out)} -
- )} -
- -
- - - {isActionable && ( - - )} - - {isUpcoming && ( - - )} - - {isDone && ( -
- - {t.done} -
- )} -
-
- ); + return renderCleaningCard(a); }) )}
diff --git a/Components/admin/Cleaners/ReportIssuePage.tsx b/Components/admin/Cleaners/ReportIssuePage.tsx index c81c962f..2313b777 100644 --- a/Components/admin/Cleaners/ReportIssuePage.tsx +++ b/Components/admin/Cleaners/ReportIssuePage.tsx @@ -203,20 +203,26 @@ export default function ReportIssuePage() {
{/* Stats Cards */} -
+
{stats.map((stat, index) => { const IconComponent = stat.icon; return (
-
-
-

{stat.label}

-

{stat.value}

+
+
+

{stat.label}

+
+ {isLoadingReports ? ( +
+ ) : ( + stat.value + )} +
- +
); diff --git a/Components/admin/Cleaners/translations.ts b/Components/admin/Cleaners/translations.ts index 811ccb64..409a39be 100644 --- a/Components/admin/Cleaners/translations.ts +++ b/Components/admin/Cleaners/translations.ts @@ -64,6 +64,25 @@ const translations = { outLabel: "Out:", today: "Today", + // Next job card + nextJobTitle: "Your Next Cleaning", + nextJobSub: "Here's where to go next — tap to start", + tomorrow: "Tomorrow", + readyNow: "Ready to clean now", + nextJobNone: "No upcoming cleanings", + nextJobNoneSub: "You're all caught up. New jobs will show here.", + + // View toggle + list + listView: "List", + calendarView: "Calendar", + upcomingHeading: "Upcoming", + completedHeading: "Completed", + completedLast7: "Last 7 days", + completedThisMonth: "This month", + completedNoneRange: "Nothing completed in this range.", + listEmpty: "No cleanings yet", + listEmptySub: "Your assigned jobs will appear here.", + // Checklist page checklistTitle: "Cleaning Checklist", checklistSubtitle: "Complete cleaning tasks for your assigned haven", @@ -177,6 +196,25 @@ const translations = { outLabel: "Labas:", today: "Ngayon", + // Next job card + nextJobTitle: "Susunod Mong Lilinisin", + nextJobSub: "Ito ang susunod mong pupuntahan — pindutin para magsimula", + tomorrow: "Bukas", + readyNow: "Pwede nang linisin ngayon", + nextJobNone: "Walang darating na lilinisin", + nextJobNoneSub: "Wala ka pang trabaho. Lalabas dito ang mga bago.", + + // View toggle + list + listView: "Talaan", + calendarView: "Kalendaryo", + upcomingHeading: "Mga Darating", + completedHeading: "Tapos Na", + completedLast7: "Huling 7 araw", + completedThisMonth: "Ngayong buwan", + completedNoneRange: "Walang natapos sa panahong ito.", + listEmpty: "Wala pang lilinisin", + listEmptySub: "Lalabas dito ang mga trabaho mo.", + // Checklist page checklistTitle: "Listahan ng Linis", checklistSubtitle: "I-check ang mga trabaho sa paglilinis", diff --git a/Components/admin/Csr/MessagePage.tsx b/Components/admin/Csr/MessagePage.tsx index e79454f5..b1d0b07e 100644 --- a/Components/admin/Csr/MessagePage.tsx +++ b/Components/admin/Csr/MessagePage.tsx @@ -9,6 +9,7 @@ import { Smile, X, Loader2, + Play, } from "lucide-react"; import Image from "next/image"; import { @@ -116,6 +117,26 @@ const getActiveStatus = (lastMessageTime: string | undefined, type: string) => { return { isActive: false, statusText: "Offline" }; }; +// Detect a media attachment on a message. Newer attachments are Cloudinary URLs +// (image or video); older ones may be inline base64 or the image_url column. +const getMessageMedia = ( + m: { image_url?: string | null; message_text?: string }, +): { src: string; type: "image" | "video" } | null => { + if (m.image_url) { + return { + src: m.image_url, + type: /\/video\/upload\//i.test(m.image_url) ? "video" : "image", + }; + } + const txt = m.message_text || ""; + if (txt.startsWith("data:image")) return { src: txt, type: "image" }; + if (txt.startsWith("data:video")) return { src: txt, type: "video" }; + if (/res\.cloudinary\.com/i.test(txt)) { + return { src: txt, type: /\/video\/upload\//i.test(txt) ? "video" : "image" }; + } + return null; +}; + // Skeleton component defined outside the main component const Skeleton = ({ className }: { className: string }) => (
@@ -141,7 +162,8 @@ export default function MessagePage({ return guestName || ""; }, [session?.user, userEmail, guestName]); const [draft, setDraft] = useState(""); - const [pendingImage, setPendingImage] = useState(null); + const [pendingMedia, setPendingMedia] = useState<{ url: string; type: "image" | "video" } | null>(null); + const [isUploadingMedia, setIsUploadingMedia] = useState(false); const [zoomedImage, setZoomedImage] = useState(null); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); @@ -390,8 +412,9 @@ export default function MessagePage({ const handleSendMessage = async () => { const text = draft.trim(); - if (!text && !pendingImage) return; + if (!text && !pendingMedia) return; if (!activeId || !userId) return; + if (isUploadingMedia) return; try { await sendMessage({ @@ -399,11 +422,11 @@ export default function MessagePage({ sender_id: userId, sender_name: currentUserName || "CSR", message_text: text, - image: pendingImage || undefined, + image: pendingMedia?.url || undefined, }).unwrap(); setDraft(""); - setPendingImage(null); + setPendingMedia(null); refetchMessages(); refetchConversations(); } catch (error: unknown) { @@ -743,31 +766,51 @@ export default function MessagePage({ {senderLabel} )} -
- {m.image_url || m.message_text?.startsWith("data:image") ? ( - <> - sent image setZoomedImage(m.image_url ?? m.message_text)} - /> - {m.message_text && !m.message_text.startsWith("data:image") && ( -

- {m.message_text} -

+ {(() => { + const media = getMessageMedia(m); + // Caption: text alongside an attachment, but not the media URL itself. + const caption = + media && m.message_text && m.message_text !== media.src + ? m.message_text + : null; + return ( +
+ {media ? ( + <> + {media.type === "video" ? ( +
+
+ ); + })()} {memoizedFormatMessageTime(m.created_at)} @@ -786,16 +829,31 @@ export default function MessagePage({
- {pendingImage && ( + {isUploadingMedia && ( +
+ + Uploading… +
+ )} + {pendingMedia && !isUploadingMedia && (
- preview + {pendingMedia.type === "video" ? ( +
+
+ ) : ( + preview + )}
diff --git a/app/api/messages/upload/route.ts b/app/api/messages/upload/route.ts new file mode 100644 index 00000000..8e7f9ff9 --- /dev/null +++ b/app/api/messages/upload/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { upload_file } from "@/backend/utils/cloudinary"; +import { requireEmployee } from "@/backend/utils/requireAdmin"; + +// Max upload sizes (bytes). Images are small; video is capped to keep +// uploads/playback reasonable for cleaners on mobile data. +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10 MB +const MAX_VIDEO_BYTES = 50 * 1024 * 1024; // 50 MB + +export async function POST(req: NextRequest) { + const guard = await requireEmployee(); + if (!guard.ok) return guard.response; + + try { + const formData = await req.formData(); + const file = formData.get("file") as File | null; + + if (!file) { + return NextResponse.json( + { success: false, error: "file is required" }, + { status: 400 }, + ); + } + + const isImage = file.type.startsWith("image/"); + const isVideo = file.type.startsWith("video/"); + if (!isImage && !isVideo) { + return NextResponse.json( + { success: false, error: "Only image and video files are allowed" }, + { status: 400 }, + ); + } + + const maxBytes = isVideo ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES; + if (file.size > maxBytes) { + const mb = Math.round(maxBytes / (1024 * 1024)); + return NextResponse.json( + { success: false, error: `${isVideo ? "Video" : "Image"} must be smaller than ${mb} MB.` }, + { status: 400 }, + ); + } + + const bytes = await file.arrayBuffer(); + const base64 = Buffer.from(bytes).toString("base64"); + const dataUrl = `data:${file.type};base64,${base64}`; + + const uploadResult = await upload_file(dataUrl, "staycation-haven/message-media"); + + return NextResponse.json({ + success: true, + url: uploadResult.url, + type: isVideo ? "video" : "image", + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("POST /api/messages/upload error:", message); + return NextResponse.json({ success: false, error: message }, { status: 500 }); + } +} From 76009b2b120c915f86dcd7e398ff3df3a16c51e3 Mon Sep 17 00:00:00 2001 From: roje1030 Date: Thu, 11 Jun 2026 16:34:43 +0800 Subject: [PATCH 4/5] remove the word management, add button in profile, and csr CsrDashboardPage --- Components/admin/Csr/BookingPage.tsx | 148 ++++++++++++---------- Components/admin/Csr/CsrDashboardPage.tsx | 1 - Components/admin/Csr/ProfilePage.tsx | 69 +++++++++- backend/controller/employeeController.ts | 6 +- 4 files changed, 152 insertions(+), 72 deletions(-) diff --git a/Components/admin/Csr/BookingPage.tsx b/Components/admin/Csr/BookingPage.tsx index 85eee63f..4ef4f730 100644 --- a/Components/admin/Csr/BookingPage.tsx +++ b/Components/admin/Csr/BookingPage.tsx @@ -1,6 +1,6 @@ "use client"; -import { Calendar, Search, Filter, Plus, XCircle, CheckSquare, Eye, Edit, Trash2, MapPin, User, Phone, Mail, CheckCircle, Clock, LogIn, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowUpDown, Download, FileSpreadsheet, RefreshCw, Check, X, ExternalLink, CreditCard, Banknote, Shield, ShieldAlert } from "lucide-react"; +import { Calendar, Search, Filter, Plus, XCircle, CheckSquare, Eye, Edit, Trash2, MapPin, User, Phone, Mail, CheckCircle, Clock, LogIn, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowUpDown, Download, FileSpreadsheet, RefreshCw, Check, X, ExternalLink, CreditCard, Banknote, Shield, ShieldAlert, HelpCircle } from "lucide-react"; import { useEffect, useMemo, useState, useRef } from "react"; import { useSession } from "next-auth/react"; import ViewBookings from "./Modals/ViewBookings"; @@ -62,6 +62,7 @@ export default function BookingsPage() { const employeeId = session?.user?.id; const [searchTerm, setSearchTerm] = useState(""); + const [showGuideDrawer, setShowGuideDrawer] = useState(false); const [filterStatus, setFilterStatus] = useState("all"); const [selectedHaven, setSelectedHaven] = useState("all"); const [checkInDateFrom, setCheckInDateFrom] = useState(""); @@ -756,52 +757,69 @@ export default function BookingsPage() { {/* Header */}
-

Bookings Management

+

Bookings

Manage all customer bookings and reservations

+
- {/* Booking Status Guide */} -
-

Booking Status Guide

-
-
-
-
-
Pending
-

Booking awaiting approval or payment confirmation

-
-
-
-
-
-
On-going
-

Down payment accepted, booking is confirmed

-
-
-
-
-
-
Approved
-

Booking confirmed and approved by management

-
-
-
-
-
-
Checked-In
-

Guest has arrived and checked in to the haven

+ {/* Help & Guides Drawer */} + {showGuideDrawer && ( +
+ {/* Backdrop */} +
setShowGuideDrawer(false)} + /> + + {/* Panel */} +
+ {/* Drawer Header */} +
+
+ +

Help & Guides

+
+
-
-
-
-
-
Checked-Out
-

Guest has completed their stay

+ + {/* Content */} +
+

Booking Status Guide

+
+ {[ + { name: "Pending", color: "bg-yellow-500", description: "Booking awaiting approval or payment confirmation" }, + { name: "On-going", color: "bg-teal-500", description: "Down payment accepted, booking is confirmed" }, + { name: "Approved", color: "bg-green-500", description: "Booking confirmed and approved by management" }, + { name: "Checked-In", color: "bg-blue-500", description: "Guest has arrived and checked in to the haven" }, + { name: "Checked-Out", color: "bg-red-500", description: "Guest has completed their stay" }, + ].map((status) => ( +
+
+
+
{status.name}
+

{status.description}

+
+
+ ))} +
-
+ )} {/* Stats Cards */}
@@ -1121,7 +1139,7 @@ export default function BookingsPage() { - - + - + @@ -1194,47 +1212,47 @@ export default function BookingsPage() { key={`skeleton-${idx}`} className="border-b border-gray-100 dark:border-gray-700 animate-pulse" > - - - - - - - - - - - - - - - - -
+ handleSort("id")} - className="text-left py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" + className="text-left py-2.5 px-3 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" >
Booking ID @@ -1148,7 +1166,7 @@ export default function BookingsPage() {
handleSort("haven")} - className="text-left py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" + className="text-left py-2.5 px-3 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" >
Haven & Booking @@ -1157,7 +1175,7 @@ export default function BookingsPage() {
handleSort("guestName")} - className="text-left py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" + className="text-left py-2.5 px-3 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" >
Guest @@ -1166,24 +1184,24 @@ export default function BookingsPage() {
handleSort("checkIn")} - className="text-left py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" + className="text-left py-2.5 px-3 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors group whitespace-nowrap" >
Check-In / Check-Out
TotalTotal handleSort("status")} - className="text-center py-4 px-4 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors whitespace-nowrap" + className="text-center py-2.5 px-3 text-sm font-bold text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors whitespace-nowrap" >
Status
ActionsActions
+
+
+
+
+
+
+
+
+
+
@@ -1268,7 +1286,7 @@ export default function BookingsPage() { key={booking.id} className={`border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${selectedBookings.includes(booking.id) ? 'bg-brand-primary/5' : ''}`} > -
+ +
{booking.booking_id} {booking.payment_method && ( @@ -1315,7 +1333,7 @@ export default function BookingsPage() { )}
+
@@ -1327,7 +1345,7 @@ export default function BookingsPage() {
+
@@ -1379,7 +1397,7 @@ export default function BookingsPage() { )}
+ + +
{booking.status} @@ -1414,7 +1432,7 @@ export default function BookingsPage() { )}
+
{booking.deposit_status?.toLowerCase() === 'pending_verification' && ( + {isEditing && ( + + )} + {isEditing && !removePhoto && (profilePreview || profileImage) && ( + + )}
diff --git a/backend/controller/employeeController.ts b/backend/controller/employeeController.ts index 5f950466..bedb0f1c 100644 --- a/backend/controller/employeeController.ts +++ b/backend/controller/employeeController.ts @@ -233,11 +233,15 @@ export const updateEmployee = async (req: NextRequest): Promise => const values: any[] = []; let paramCount = 1; - // Add profile_image_url if it exists + // Add profile_image_url if a new one was provided, or clear it when the + // caller explicitly passes null (user removed their photo). Callers that + // omit the field send undefined and are left untouched. if (profileImageUrl) { fields.push(`profile_image_url = $${paramCount}`); values.push(profileImageUrl); paramCount++; + } else if (profile_image_url === null) { + fields.push(`profile_image_url = NULL`); } Object.entries(employeeData).forEach(([key, value]) => { From 2b40ba1fb827c74cc8898ee65e1b9000e076cce8 Mon Sep 17 00:00:00 2001 From: roje1030 Date: Wed, 17 Jun 2026 13:07:08 +0800 Subject: [PATCH 5/5] b --- .gitignore | 4 ++++ backend/controller/bookingController.ts | 11 +++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 1afd8c25..f7a6866c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,10 @@ yarn-error.log* .env.local database.sql +# database backups (contain guest PII + password hashes — never commit) +/backups +_diag_booking.js + # vercel .vercel diff --git a/backend/controller/bookingController.ts b/backend/controller/bookingController.ts index cf2d6b6d..d7754c2a 100644 --- a/backend/controller/bookingController.ts +++ b/backend/controller/bookingController.ts @@ -761,14 +761,16 @@ export const createBooking = async ( amount_paid: paymentAmountPaid, }); - // Note: remaining_balance is a GENERATED column in the live DB - // (computed as total_amount - amount_paid). Do not include it in INSERT. + // remaining_balance is a NOT NULL column (= total_amount - amount_paid), + // matching the CHECK constraint in bookings.sql. Compute and supply it + // explicitly so the insert satisfies the constraint on the rebuilt schema. + const paymentRemainingBalance = paymentTotalAmount - paymentAmountPaid; const paymentQuery = ` INSERT INTO booking_payments ( booking_id, payment_method, payment_proof_url, room_rate, - add_ons_total, total_amount, down_payment, amount_paid + add_ons_total, total_amount, down_payment, amount_paid, remaining_balance ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `; const paymentValues = [ @@ -780,6 +782,7 @@ export const createBooking = async ( paymentTotalAmount, paymentDownPayment, paymentAmountPaid, + paymentRemainingBalance, ]; console.log("📝 [BOOKING] Inserting payment record...");