diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 393ed23d..5f0bdd6e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(git mv:*)" + "Bash(git mv:*)", + "Bash(node -e ' *)" ] } } diff --git a/Components/admin/Cleaners/CleanersDashboard.tsx b/Components/admin/Cleaners/CleanersDashboard.tsx index f8854518..600289eb 100644 --- a/Components/admin/Cleaners/CleanersDashboard.tsx +++ b/Components/admin/Cleaners/CleanersDashboard.tsx @@ -304,6 +304,7 @@ useEffect(() => { }, [profileDropdownOpen]); const [checklistHavenId, setChecklistHavenId] = useState(null); + const [checklistBookingId, setChecklistBookingId] = useState(null); const LANG_STORAGE_KEY = "cleaners-lang"; const [lang, setLang] = useState(() => { @@ -343,22 +344,43 @@ useEffect(() => { case "my-schedule": return ( { + onNavigate={(pg) => { + if (pg === "cleaning-checklist") { + setChecklistHavenId(null); + setChecklistBookingId(null); + } + setPage(pg); + }} + onStartCleaning={(havenId, bookingId) => { setChecklistHavenId(havenId); + setChecklistBookingId(bookingId ?? null); setPage("cleaning-checklist"); }} lang={lang} /> ); case "cleaning-checklist": - return ; + return ( + + ); default: return ( { + onNavigate={(pg) => { + if (pg === "cleaning-checklist") { + setChecklistHavenId(null); + setChecklistBookingId(null); + } + setPage(pg); + }} + onStartCleaning={(havenId, bookingId) => { setChecklistHavenId(havenId); + setChecklistBookingId(bookingId ?? null); setPage("cleaning-checklist"); }} lang={lang} diff --git a/Components/admin/Cleaners/CleaningChecklistPage.tsx b/Components/admin/Cleaners/CleaningChecklistPage.tsx index 68ea5ba0..9a1b927a 100644 --- a/Components/admin/Cleaners/CleaningChecklistPage.tsx +++ b/Components/admin/Cleaners/CleaningChecklistPage.tsx @@ -13,6 +13,8 @@ import { User, Home, AlertCircle, + Camera, + Upload, } from "lucide-react"; import React, { useEffect, useState, useCallback } from "react"; import toast from "react-hot-toast"; @@ -45,20 +47,25 @@ type Haven = { interface Props { /** Passed from MySchedulePage when "Start Cleaning" is clicked */ initialHavenId?: string | null; + /** booking_uuid from CleaningTask — scopes the checklist to this specific booking */ + initialBookingId?: string | null; lang?: Lang; } -export default function CleaningChecklistPage({ initialHavenId, lang = "en" }: Props = {}) { +export default function CleaningChecklistPage({ initialHavenId, initialBookingId, lang = "en" }: Props = {}) { const t = useTranslations(lang); const [havens, setHavens] = useState([]); const [selectedHavenId, setSelectedHavenId] = useState(null); - const [isHavensLoading, setIsHavensLoading] = useState(false); + const [, setIsHavensLoading] = useState(false); const [selectedHaven, setSelectedHaven] = useState(null); const [checklist, setChecklist] = useState([]); const [checklistId, setChecklistId] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [categoryPhotos, setCategoryPhotos] = useState>({}); + const [uploadingCategory, setUploadingCategory] = useState(null); + const [lightboxUrl, setLightboxUrl] = useState(null); const iconMap = { Bedroom: BedDouble, @@ -84,19 +91,27 @@ export default function CleaningChecklistPage({ initialHavenId, lang = "en" }: P if (Array.isArray(data)) { setHavens(data); if (data.length > 0) { - // Priority: initialHavenId prop → currently selected → first in list - const preferred = - initialHavenId - ? data.find((h: Haven) => String(h.id) === String(initialHavenId)) + if (initialHavenId) { + // Always trust the haven UUID passed from My Schedule. + // Haven 5 may be the only "checked-out needing cleaning" haven in the + // main list, but the clicked haven (via include_id) is prepended at + // position 0 by the API. Use initialHavenId directly so the checklist + // is always fetched for the correct haven regardless of the list order. + const exactMatch = data.find((h: Haven) => String(h.id) === String(initialHavenId)); + // data[0] is the include_id-matched haven (unshifted to front by API) + const havenData = exactMatch ?? data[0]; + setSelectedHavenId(initialHavenId); + setSelectedHaven(havenData); + } else { + // Direct tab navigation (no specific haven selected) — use currently + // selected haven if still in list, otherwise the first available one. + const stillExists = selectedHavenId + ? data.find((h: Haven) => h.id === selectedHavenId) : null; - - const stillExists = !preferred && selectedHavenId - ? data.find((h: Haven) => h.id === selectedHavenId) - : null; - - const target = preferred ?? stillExists ?? data[0]; - setSelectedHavenId(target.id); - setSelectedHaven(target); + const target = stillExists ?? data[0]; + setSelectedHavenId(target.id); + setSelectedHaven(target); + } } else { setSelectedHavenId(null); setSelectedHaven(null); @@ -117,28 +132,29 @@ export default function CleaningChecklistPage({ initialHavenId, lang = "en" }: P return () => { mounted = false; }; - }, []); + }, [initialHavenId]); - // Fetch checklist for a haven + // Fetch checklist for a haven (optionally scoped to a specific booking) const fetchChecklist = useCallback( - async (havenId: string) => { + async (havenId: string, bookingId?: string | null) => { setIsLoading(true); try { - const res = await fetch( - `/api/admin/cleaners?haven_id=${encodeURIComponent(havenId)}`, - { + let apiUrl = `/api/admin/cleaners?haven_id=${encodeURIComponent(havenId)}`; + if (bookingId) apiUrl += `&booking_id=${encodeURIComponent(bookingId)}`; + const res = await fetch(apiUrl, { cache: "no-store", - }, - ); + }); const payload = await res.json(); if (res.ok && payload.success && payload.data?.checklist) { const { checklist } = payload.data; setChecklistId(checklist.id); setChecklist(checklist.categories || []); - // Use haven info if the API returns it alongside the checklist + // Update haven info only when not already set from include_id response if (payload.data.haven) { setSelectedHaven(payload.data.haven); - } else { + } else if (!initialHavenId) { + // Only fall back to the havens list when no specific haven was passed in; + // otherwise the correct haven data is already in selectedHaven. const found = havens.find((h) => h.id === checklist.haven_id); if (found) setSelectedHaven(found); } @@ -158,8 +174,60 @@ export default function CleaningChecklistPage({ initialHavenId, lang = "en" }: P [havens] ); - // Separately fetch haven info for the booking card (in case checklist endpoint doesn't return it) + const fetchPhotos = useCallback(async (checklistId: string) => { + try { + const res = await fetch( + `/api/admin/cleaners/checklist-photos?checklist_id=${encodeURIComponent(checklistId)}`, + { cache: "no-store" }, + ); + const payload = await res.json(); + if (res.ok && payload.success) { + setCategoryPhotos(payload.data || {}); + } + } catch { + // non-fatal — photos just won't preload + } + }, []); + + const handlePhotoChange = useCallback( + async (e: React.ChangeEvent, categoryName: string) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file || !checklistId) return; + + setUploadingCategory(categoryName); + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("checklist_id", checklistId); + formData.append("category", categoryName); + + const res = await fetch("/api/admin/cleaners/checklist-photos", { + method: "POST", + body: formData, + }); + const payload = await res.json(); + if (!res.ok || !payload.success) { + throw new Error(payload.error || "Upload failed"); + } + setCategoryPhotos((prev) => ({ ...prev, [categoryName]: payload.url })); + toast.success(t.photoSaved); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + toast.error(message || "Upload failed"); + } finally { + setUploadingCategory(null); + } + }, + [checklistId, t.photoSaved], + ); + + // Separately fetch haven info for the booking card (only used when no initialHavenId + // was provided, i.e., direct tab navigation). When initialHavenId IS provided, the + // haven data is already set from the include_id response and we must NOT overwrite it + // with the main havens list which may not contain the upcoming haven. const fetchHavenInfo = useCallback(async (havenId: string) => { + if (initialHavenId) return; // haven info already set from include_id response try { const res = await fetch(`/api/admin/cleaners/havens`, { cache: "no-store" }); const data = await res.json(); @@ -170,22 +238,30 @@ export default function CleaningChecklistPage({ initialHavenId, lang = "en" }: P } catch { // non-fatal — booking info card just won't show } - }, []); + }, [initialHavenId]); useEffect(() => { if (selectedHavenId) { - fetchChecklist(selectedHavenId); + fetchChecklist(selectedHavenId, initialBookingId); fetchHavenInfo(selectedHavenId); } else { setChecklist([]); setChecklistId(null); setSelectedHaven(null); } - }, [selectedHavenId, fetchChecklist, fetchHavenInfo]); + }, [selectedHavenId, initialBookingId, fetchChecklist, fetchHavenInfo]); + + useEffect(() => { + if (checklistId) { + fetchPhotos(checklistId); + } else { + setCategoryPhotos({}); + } + }, [checklistId, fetchPhotos]); + const toggleTask = async (taskId: string) => { let newCompleted = false; - setChecklist((prev) => prev.map((category: Category) => ({ ...category, @@ -198,7 +274,6 @@ export default function CleaningChecklistPage({ initialHavenId, lang = "en" }: P }), })), ); - try { const res = await fetch("/api/admin/cleaners", { method: "PATCH", @@ -206,42 +281,16 @@ export default function CleaningChecklistPage({ initialHavenId, lang = "en" }: P body: JSON.stringify({ task_id: taskId, completed: newCompleted }), }); const payload = await res.json(); - if (!res.ok) { - throw new Error(payload?.error || "Failed to update task"); - } - - const returnedTask = payload?.data?.task; - if ( - returnedTask && - returnedTask.checklist_id && - returnedTask.checklist_id !== checklistId - ) { - if (initialHavenId) { - await fetchChecklist(initialHavenId); - toast.success("Task updated; checklist refreshed (task moved to latest)"); - } else { - toast.success("Task updated"); - } - return; - } - + if (!res.ok) throw new Error(payload?.error || "Failed to update task"); toast.success("Task updated"); } catch (err) { console.error("Failed to update task:", err); const message = err instanceof Error ? err.message : String(err); toast.error(message || "Failed to update task"); - if (initialHavenId) fetchChecklist(initialHavenId); + if (selectedHavenId) fetchChecklist(selectedHavenId, initialBookingId); } }; - const totalTasks = checklist.reduce((acc, cat) => acc + cat.tasks.length, 0); - const completedTasks = checklist.reduce( - (acc, cat: Category) => acc + cat.tasks.filter((t: Task) => t.completed).length, - 0, - ); - const progress = totalTasks === 0 ? 0 : Math.round((completedTasks / totalTasks) * 100); - const canComplete = progress === 100; - // Empty / no task selected — shown when navigating directly to the tab without clicking Start Cleaning if (!initialHavenId && !isLoading) { return ( @@ -333,34 +382,6 @@ export default function CleaningChecklistPage({ initialHavenId, lang = "en" }: P )} - {/* Progress Overview */} -
-
-
-

- {t.overallProgress} -

-

- - {completedTasks}/{totalTasks} - {" "} - {t.tasksCompleted} -

-
-
-

{progress}%

-
-
-
-
-
-
- {/* Checklist by Category */}
@@ -406,64 +427,121 @@ export default function CleaningChecklistPage({ initialHavenId, lang = "en" }: P checklist.map((category: Category) => { const CategoryIcon = (iconMap as Record)[category.category] ?? Sparkles; - const categoryCompleted = category.tasks.filter((t) => t.completed).length; - const categoryTotal = category.tasks.length; - const categoryProgress = Math.round( - (categoryCompleted / Math.max(1, categoryTotal)) * 100, - ); - return (
-
-
-
- -
-
-

- {category.category} -

-

- {t.ofCompleted(categoryCompleted, categoryTotal)} -

-
+
+
+
- - {categoryProgress}% - +

+ {category.category} +

-
- {category.tasks.map((task: Task) => ( -
{ if (!selectedHaven?.isUpcoming) toggleTask(task.id); }} - className={`flex items-center gap-3 p-3 rounded-lg transition-all ${selectedHaven?.isUpcoming ? "cursor-not-allowed opacity-60" : "cursor-pointer"} ${ - task.completed - ? "bg-green-50 dark:bg-green-900/20" - : "bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600" - }`} - > - {task.completed ? ( - - ) : ( - - )} - + {category.tasks.map((task: Task) => ( +
{ if (!selectedHaven?.isUpcoming) toggleTask(task.id); }} + className={`flex items-center gap-3 p-3 rounded-lg transition-all ${selectedHaven?.isUpcoming ? "cursor-not-allowed opacity-60" : "cursor-pointer"} ${ task.completed - ? "text-green-700 dark:text-green-400 line-through" - : "text-gray-800 dark:text-gray-100" + ? "bg-green-50 dark:bg-green-900/20" + : "bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600" }`} > - {task.task} + {task.completed ? ( + + ) : ( + + )} + + {task.task} + +
+ ))} +
+ )} + + {/* Photo proof — Bedroom, Bathroom, Kitchen, Living Room */} + {category.category !== "General" && ( +
+
+ + + {t.photoProof}
- ))} -
+ + {categoryPhotos[category.category] && ( + + )} + + handlePhotoChange(e, category.category)} + /> + handlePhotoChange(e, category.category)} + /> + +
+ + +
+
+ )}
); })} @@ -473,24 +551,12 @@ export default function CleaningChecklistPage({ initialHavenId, lang = "en" }: P {!selectedHaven?.isUpcoming && !isLoading && checklist.length > 0 && (
- {canComplete ? ( -
- - {t.allDone} -
- ) : ( -

- {t.autoSave}  · {" "} - - {totalTasks - completedTasks} task{totalTasks - completedTasks !== 1 ? "s" : ""} remaining - -

- )} +

{t.autoSave}

)} + + {/* Lightbox */} + {lightboxUrl && ( +
setLightboxUrl(null)} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + Photo proof e.stopPropagation()} + /> +
+ )}
); } diff --git a/Components/admin/Cleaners/MySchedulePage.tsx b/Components/admin/Cleaners/MySchedulePage.tsx index abc6378d..1f351fcb 100644 --- a/Components/admin/Cleaners/MySchedulePage.tsx +++ b/Components/admin/Cleaners/MySchedulePage.tsx @@ -34,7 +34,7 @@ import { useTranslations, type Lang } from "./translations"; interface Props { onNavigate?: (page: string) => void; - onStartCleaning?: (havenId: string) => void; + onStartCleaning?: (havenId: string, bookingId?: string) => void; lang?: Lang; } @@ -89,7 +89,10 @@ function endOfMonth(d: Date) { function formatTime(t: string | null | undefined): string { if (!t) return "—"; - return t.substring(0, 5); + const [h, m] = t.substring(0, 5).split(":").map(Number); + const period = h >= 12 ? "PM" : "AM"; + const hour = h % 12 || 12; + return `${hour}:${String(m).padStart(2, "0")} ${period}`; } function formatDate(s: string): string { @@ -886,9 +889,9 @@ export default function MySchedulePage({ onNavigate = () => {}, onStartCleaning,