import { useEffect, useMemo, useState } from "react"; import { supabase } from "@/integrations/supabase/client"; import { useAssociation } from "@/contexts/AssociationContext"; import { useAuth } from "@/contexts/AuthContext"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import GoogleMapPicker, { type MapPinData } from "@/components/association/GoogleMapPicker"; import { toast } from "sonner"; import { Plus, Trash2, Check, ArrowUp, ArrowDown, Copy, Pencil, X } from "lucide-react"; type Lot = { id: string; association_id: string; lot_number: string; lot_type: string; size: string | null; monthly_rate: number | null; status: string; notes: string | null; }; type Rental = { id: string; association_id: string; lot_id: string; renter_name: string; renter_email: string | null; renter_phone: string | null; vehicle_description: string | null; start_date: string; end_date: string | null; monthly_rate: number | null; status: string; notes: string | null; is_owner?: boolean | null; user_id?: string | null; owner_id?: string | null; unit_id?: string | null; insurance_carrier?: string | null; insurance_policy_number?: string | null; insurance_expiration_date?: string | null; insurance_document_url?: string | null; }; type WaitlistEntry = { id: string; association_id: string; position: number; requester_name: string; requester_email: string | null; requester_phone: string | null; unit_address: string | null; requested_lot_type: string; requested_size: string | null; vehicle_description: string | null; notes: string | null; status: string; source: string; joined_at: string; fulfilled_at: string | null; fulfilled_lot_number: string | null; }; const VEHICLE_TYPES = [ { value: "rv", label: "RV" }, { value: "boat", label: "Boat" }, { value: "travel_trailer", label: "Travel Trailer" }, { value: "fifth_wheel", label: "Fifth Wheel" }, { value: "camper", label: "Camper" }, { value: "car", label: "Car" }, { value: "truck", label: "Truck" }, { value: "trailer", label: "Trailer" }, { value: "other", label: "Other" }, ]; const vehicleTypeLabel = (v: string) => VEHICLE_TYPES.find(t => t.value === v)?.label || v; export default function RVBoatLotsPage() { const associationCtx = useAssociation() as any; const ctxAssociation = associationCtx?.selectedAssociation ?? null; const { user } = useAuth(); const [associations, setAssociations] = useState<{ id: string; name: string }[]>([]); const [localAssociationId, setLocalAssociationId] = useState(""); useEffect(() => { supabase.from("associations").select("id, name").eq("status", "active").order("name").then(({ data }) => { const list = data || []; setAssociations(list); setLocalAssociationId((prev) => prev || ctxAssociation?.id || list[0]?.id || ""); }); }, [ctxAssociation?.id]); const associationId = localAssociationId || ctxAssociation?.id || ""; const [lots, setLots] = useState([]); const [rentals, setRentals] = useState([]); const [waitlist, setWaitlist] = useState([]); const [owners, setOwners] = useState([]); const [units, setUnits] = useState([]); const [loading, setLoading] = useState(true); // dialogs const [lotDialogOpen, setLotDialogOpen] = useState(false); const [rentalDialogOpen, setRentalDialogOpen] = useState(false); const [waitlistDialogOpen, setWaitlistDialogOpen] = useState(false); const [fulfillDialog, setFulfillDialog] = useState<{ open: boolean; entry: WaitlistEntry | null; lotNumber: string }>({ open: false, entry: null, lotNumber: "" }); // form state const [lotForm, setLotForm] = useState({ lot_number: "", lot_type: "rv", size: "", monthly_rate: "", notes: "" }); const [rentalForm, setRentalForm] = useState({ lot_id: "", renter_name: "", renter_email: "", renter_phone: "", vehicle_description: "", start_date: new Date().toISOString().slice(0, 10), monthly_rate: "", notes: "", owner_id: "", unit_id: "" }); const [waitlistForm, setWaitlistForm] = useState({ requester_name: "", requester_email: "", requester_phone: "", unit_address: "", requested_lot_type: "either", requested_size: "", vehicle_description: "", notes: "" }); // batch edit state const [selectedLotIds, setSelectedLotIds] = useState>(new Set()); const [lotBulkOpen, setLotBulkOpen] = useState(false); const [savingLotBulk, setSavingLotBulk] = useState(false); const [lotBulk, setLotBulk] = useState({ lot_type: "no_change", status: "no_change", monthly_rate: "" }); const [selectedRentalIds, setSelectedRentalIds] = useState>(new Set()); const [rentalBulkOpen, setRentalBulkOpen] = useState(false); const [savingRentalBulk, setSavingRentalBulk] = useState(false); const [rentalBulk, setRentalBulk] = useState({ status: "no_change", monthly_rate: "", is_owner: "no_change" }); // internal map state const [mapPins, setMapPins] = useState([]); const [mapZoom, setMapZoom] = useState(17); const [mapLockedView, setMapLockedView] = useState<{ lat: number; lng: number; zoom: number } | null>(null); const [savingMap, setSavingMap] = useState(false); const toggleSet = (setter: React.Dispatch>>, id: string) => setter(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; }); const toggleSetMany = (setter: React.Dispatch>>, ids: string[], checked: boolean) => setter(prev => { const n = new Set(prev); ids.forEach(id => checked ? n.add(id) : n.delete(id)); return n; }); const publicUrl = useMemo(() => { if (!associationId) return ""; return `${window.location.origin}/rv-boat-waitlist/${associationId}`; }, [associationId]); const load = async () => { if (!associationId) return; setLoading(true); try { const [lotsRes, rentalsRes, wlRes, ownersRes, unitsRes] = await Promise.all([ supabase.from("rv_boat_lots").select("*").eq("association_id", associationId).order("lot_number"), supabase.from("rv_boat_lot_rentals").select("*").eq("association_id", associationId).order("start_date", { ascending: false }), supabase.from("rv_boat_lot_waitlist").select("*").eq("association_id", associationId).order("position"), supabase.from("owners").select("id, first_name, last_name, unit_id, email, phone").eq("association_id", associationId).eq("status", "active").order("last_name"), supabase.from("units").select("id, unit_number").eq("association_id", associationId).order("unit_number"), ]); if (lotsRes.error) throw lotsRes.error; if (rentalsRes.error) throw rentalsRes.error; if (wlRes.error) throw wlRes.error; setLots(lotsRes.data || []); setRentals(rentalsRes.data || []); setWaitlist(wlRes.data || []); setOwners(ownersRes.data || []); setUnits(unitsRes.data || []); const { data: mapRow } = await supabase.from("rv_boat_lot_maps").select("config").eq("association_id", associationId).maybeSingle(); const cfg = (mapRow?.config as any) || {}; setMapPins(Array.isArray(cfg.pins) ? cfg.pins : []); setMapZoom(typeof cfg.zoom === "number" ? cfg.zoom : 17); setMapLockedView(cfg.locked_view && typeof cfg.locked_view.lat === "number" ? cfg.locked_view : null); } catch (e: any) { toast.error(e.message || "Failed to load data"); } finally { setLoading(false); } }; useEffect(() => { load(); }, [associationId]); const createLot = async () => { if (!associationId || !lotForm.lot_number.trim()) { toast.error("Lot number required"); return; } const { error } = await supabase.from("rv_boat_lots").insert({ association_id: associationId, lot_number: lotForm.lot_number.trim(), lot_type: lotForm.lot_type, size: lotForm.size || null, monthly_rate: lotForm.monthly_rate ? Number(lotForm.monthly_rate) : null, notes: lotForm.notes || null, }); if (error) { toast.error(error.message); return; } toast.success("Lot added"); setLotDialogOpen(false); setLotForm({ lot_number: "", lot_type: "rv", size: "", monthly_rate: "", notes: "" }); load(); }; const deleteLot = async (id: string) => { if (!confirm("Delete this lot?")) return; const { error } = await supabase.from("rv_boat_lots").delete().eq("id", id); if (error) { toast.error(error.message); return; } toast.success("Lot deleted"); load(); }; const applyLotBulk = async () => { const ids = [...selectedLotIds]; if (!ids.length) return; const patch: Record = {}; if (lotBulk.lot_type !== "no_change") patch.lot_type = lotBulk.lot_type; if (lotBulk.status !== "no_change") patch.status = lotBulk.status; if (lotBulk.monthly_rate.trim() !== "") patch.monthly_rate = Number(lotBulk.monthly_rate) || 0; if (Object.keys(patch).length === 0) { toast.error("No changes selected"); return; } setSavingLotBulk(true); const { error } = await supabase.from("rv_boat_lots").update(patch).in("id", ids); setSavingLotBulk(false); if (error) { toast.error(error.message); return; } toast.success(`Updated ${ids.length} lot${ids.length === 1 ? "" : "s"}`); setLotBulkOpen(false); setSelectedLotIds(new Set()); load(); }; const deleteLots = async () => { const ids = [...selectedLotIds]; if (!ids.length) return; if (!confirm(`Delete ${ids.length} lot${ids.length === 1 ? "" : "s"}? Lots with rentals may fail to delete.`)) return; const { error } = await supabase.from("rv_boat_lots").delete().in("id", ids); if (error) { toast.error(error.message); return; } toast.success(`Deleted ${ids.length} lot${ids.length === 1 ? "" : "s"}`); setSelectedLotIds(new Set()); load(); }; const applyRentalBulk = async () => { const ids = [...selectedRentalIds]; if (!ids.length) return; const patch: Record = {}; if (rentalBulk.status !== "no_change") { patch.status = rentalBulk.status; if (rentalBulk.status === "ended") patch.end_date = new Date().toISOString().slice(0, 10); } if (rentalBulk.monthly_rate.trim() !== "") patch.monthly_rate = Number(rentalBulk.monthly_rate) || 0; if (rentalBulk.is_owner !== "no_change") patch.is_owner = rentalBulk.is_owner === "true"; if (Object.keys(patch).length === 0) { toast.error("No changes selected"); return; } setSavingRentalBulk(true); const { error } = await supabase.from("rv_boat_lot_rentals").update(patch).in("id", ids); setSavingRentalBulk(false); if (error) { toast.error(error.message); return; } toast.success(`Updated ${ids.length} rental${ids.length === 1 ? "" : "s"}`); setRentalBulkOpen(false); setSelectedRentalIds(new Set()); load(); }; const createRental = async () => { if (!associationId || !rentalForm.lot_id || !rentalForm.renter_name.trim()) { toast.error("Lot and renter name required"); return; } const { error } = await supabase.from("rv_boat_lot_rentals").insert({ association_id: associationId, lot_id: rentalForm.lot_id, renter_name: rentalForm.renter_name.trim(), renter_email: rentalForm.renter_email || null, renter_phone: rentalForm.renter_phone || null, vehicle_description: rentalForm.vehicle_description || null, start_date: rentalForm.start_date, monthly_rate: rentalForm.monthly_rate ? Number(rentalForm.monthly_rate) : null, notes: rentalForm.notes || null, owner_id: rentalForm.owner_id || null, unit_id: rentalForm.unit_id || null, status: "active", }); if (error) { toast.error(error.message); return; } toast.success("Rental created"); setRentalDialogOpen(false); setRentalForm({ lot_id: "", renter_name: "", renter_email: "", renter_phone: "", vehicle_description: "", start_date: new Date().toISOString().slice(0, 10), monthly_rate: "", notes: "", owner_id: "", unit_id: "" }); load(); }; const endRental = async (r: Rental) => { if (!confirm("End this rental? The lot will be marked available.")) return; const { error } = await supabase.from("rv_boat_lot_rentals") .update({ status: "ended", end_date: new Date().toISOString().slice(0, 10) }) .eq("id", r.id); if (error) { toast.error(error.message); return; } toast.success("Rental ended"); load(); }; const invitePortal = async (r: Rental, asOwner = false) => { const label = asOwner ? "owner" : "renter"; const email = window.prompt(`Email address for the ${label}'s portal account:`, r.renter_email || ""); if (!email) return; const trimmed = email.trim(); if (!trimmed) return; try { const { data, error } = await supabase.functions.invoke("invite-rv-renter", { body: { rental_id: r.id, email: trimmed, as_owner: asOwner }, }); if (error) throw error; if ((data as any)?.error) throw new Error((data as any).error); toast.success((data as any)?.invited ? "Invitation email sent" : "Existing user linked; password reset emailed"); load(); } catch (e: any) { toast.error(e.message || "Failed to invite"); } }; const saveMap = async () => { if (!associationId) return; setSavingMap(true); const { error } = await supabase.from("rv_boat_lot_maps").upsert({ association_id: associationId, config: { pins: mapPins, zoom: mapZoom, locked_view: mapLockedView }, updated_at: new Date().toISOString(), }, { onConflict: "association_id" }); setSavingMap(false); if (error) { toast.error(error.message); return; } toast.success("Map saved"); }; const requestInsurance = async (r: Rental) => { if (!r.renter_email) { toast.error("Add a renter email first, then request insurance."); return; } try { const { data, error } = await supabase.functions.invoke("send-rv-renter-insurance-request", { body: { rental_id: r.id }, }); if (error) throw error; const res = data as any; if (res?.error && !res?.link) throw new Error(res.error); if (res?.ok) { toast.success(`Insurance request emailed to ${res.sent_to}`); } else if (res?.link) { await navigator.clipboard.writeText(res.link).catch(() => {}); toast.message("Email didn't send — link copied", { description: "Share the submission link with the renter manually." }); } load(); } catch (e: any) { toast.error(e.message || "Failed to request insurance"); } }; const toggleIsOwner = async (r: Rental) => { const next = !r.is_owner; const { error } = await supabase.from("rv_boat_lot_rentals") .update({ is_owner: next }) .eq("id", r.id); if (error) { toast.error(error.message); return; } toast.success(next ? "Marked as owner" : "Marked as renter"); load(); }; const addWaitlist = async () => { if (!associationId || !waitlistForm.requester_name.trim()) { toast.error("Name required"); return; } const { error } = await supabase.from("rv_boat_lot_waitlist").insert({ association_id: associationId, requester_name: waitlistForm.requester_name.trim(), requester_email: waitlistForm.requester_email || null, requester_phone: waitlistForm.requester_phone || null, unit_address: waitlistForm.unit_address || null, requested_lot_type: waitlistForm.requested_lot_type, requested_size: waitlistForm.requested_size || null, vehicle_description: waitlistForm.vehicle_description || null, notes: waitlistForm.notes || null, source: "manual", status: "waiting", position: 0, }); if (error) { toast.error(error.message); return; } toast.success("Added to waitlist"); setWaitlistDialogOpen(false); setWaitlistForm({ requester_name: "", requester_email: "", requester_phone: "", unit_address: "", requested_lot_type: "either", requested_size: "", vehicle_description: "", notes: "" }); load(); }; const movePosition = async (entry: WaitlistEntry, direction: "up" | "down") => { const waiting = waitlist.filter(w => w.status === "waiting").sort((a, b) => a.position - b.position); const idx = waiting.findIndex(w => w.id === entry.id); const swapIdx = direction === "up" ? idx - 1 : idx + 1; if (swapIdx < 0 || swapIdx >= waiting.length) return; const other = waiting[swapIdx]; // Use temporary position to avoid unique-ish collisions if any (none enforced, but safe) const tempPos = -Math.floor(Math.random() * 100000) - 1; await supabase.from("rv_boat_lot_waitlist").update({ position: tempPos }).eq("id", entry.id); await supabase.from("rv_boat_lot_waitlist").update({ position: entry.position }).eq("id", other.id); await supabase.from("rv_boat_lot_waitlist").update({ position: other.position }).eq("id", entry.id); load(); }; const removeWaitlist = async (id: string) => { if (!confirm("Remove this entry from the waitlist?")) return; const { error } = await supabase.from("rv_boat_lot_waitlist").update({ status: "removed" }).eq("id", id); if (error) { toast.error(error.message); return; } toast.success("Removed"); load(); }; const openFulfill = (entry: WaitlistEntry) => { setFulfillDialog({ open: true, entry, lotNumber: "" }); }; const confirmFulfill = async () => { const entry = fulfillDialog.entry; if (!entry) return; if (!fulfillDialog.lotNumber.trim()) { toast.error("Enter the lot number assigned"); return; } const { error } = await supabase.from("rv_boat_lot_waitlist").update({ status: "fulfilled", fulfilled_at: new Date().toISOString(), fulfilled_lot_number: fulfillDialog.lotNumber.trim(), fulfilled_by: user?.id || null, }).eq("id", entry.id); if (error) { toast.error(error.message); return; } toast.success("Marked fulfilled"); setFulfillDialog({ open: false, entry: null, lotNumber: "" }); load(); }; const copyPublicLink = async () => { await navigator.clipboard.writeText(publicUrl); toast.success("Public waitlist link copied"); }; const waitingEntries = waitlist.filter(w => w.status === "waiting").sort((a, b) => a.position - b.position); const fulfilledEntries = waitlist.filter(w => w.status === "fulfilled" || w.status === "removed"); return (

RV / Boat Lot Rentals

Manage lot inventory, active rentals, and the waitlist.

{!associationId ? (

Select an association to manage RV/Boat lots.

) : null} Waitlist ({waitingEntries.length}) Lots ({lots.length}) Active Rentals ({rentals.filter(r => r.status === "active").length}) Map History {/* Waitlist */} Current Waitlist Add waitlist entry New entries are added to the bottom of the list.
setWaitlistForm({ ...waitlistForm, requester_name: e.target.value })} />
setWaitlistForm({ ...waitlistForm, requester_email: e.target.value })} />
setWaitlistForm({ ...waitlistForm, requester_phone: e.target.value })} />
setWaitlistForm({ ...waitlistForm, unit_address: e.target.value })} />
setWaitlistForm({ ...waitlistForm, requested_size: e.target.value })} />
setWaitlistForm({ ...waitlistForm, vehicle_description: e.target.value })} />