RV/Boat Lots: link rentals to owner/unit + Notice to Vacate toggle

Phase 2: rental form now has optional Owner and Unit selectors (auto-fills
renter info/unit from the chosen owner), persisting owner_id/unit_id.

Phase 3: add RvRentalVacateButton on Owner and Unit profiles — shows only
when that owner/unit has an active/vacating RV-boat rental, and toggles the
rental status between active (renting) and vacating. Active Rentals tab now
includes vacating rentals with a badge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 18:20:58 -04:00
parent 7c18576390
commit d8465f2297
4 changed files with 98 additions and 4 deletions
+48
View File
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { LogOut, RotateCcw } from "lucide-react";
/**
* Notice-to-Vacate toggle for an RV/Boat lot rental linked to an owner or unit.
* Renders nothing if there's no active/vacating rental for the given owner/unit.
* Toggles the rental status between "active" (renting) and "vacating".
*/
export default function RvRentalVacateButton({ ownerId, unitId }: { ownerId?: string; unitId?: string }) {
const [rental, setRental] = useState<any>(null);
const [saving, setSaving] = useState(false);
const fetchRental = useCallback(async () => {
if (!ownerId && !unitId) { setRental(null); return; }
let q = supabase
.from("rv_boat_lot_rentals")
.select("id, status, renter_name")
.in("status", ["active", "vacating"]);
q = ownerId ? q.eq("owner_id", ownerId) : q.eq("unit_id", unitId!);
const { data } = await q.order("start_date", { ascending: false }).limit(1);
setRental(data?.[0] || null);
}, [ownerId, unitId]);
useEffect(() => { fetchRental(); }, [fetchRental]);
if (!rental) return null;
const isVacating = rental.status === "vacating";
const toggle = async () => {
setSaving(true);
const next = isVacating ? "active" : "vacating";
const { error } = await supabase.from("rv_boat_lot_rentals").update({ status: next }).eq("id", rental.id);
setSaving(false);
if (error) { toast.error(error.message); return; }
toast.success(isVacating ? "Marked as renting" : "Notice to vacate recorded");
fetchRental();
};
return (
<Button variant={isVacating ? "outline" : "destructive"} size="sm" className="gap-1.5" disabled={saving} onClick={toggle}>
{isVacating ? <RotateCcw className="h-3.5 w-3.5" /> : <LogOut className="h-3.5 w-3.5" />}
{isVacating ? "Mark Renting" : "Notice to Vacate"}
</Button>
);
}
+2
View File
@@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import RvRentalVacateButton from "@/components/RvRentalVacateButton";
import { import {
ArrowLeft, Mail, Phone, MapPin, Send, AlertTriangle, StickyNote, ArrowLeft, Mail, Phone, MapPin, Send, AlertTriangle, StickyNote,
Download, Filter, FileText, Shield, ClipboardCheck, MessageCircle, Download, Filter, FileText, Shield, ClipboardCheck, MessageCircle,
@@ -263,6 +264,7 @@ export default function OwnerProfilePage() {
<Button size="sm" variant="outline" className="h-8 text-[12px] gap-1.5" onClick={() => navigate(`/dashboard/owner-updates?${ownerActionParams.toString()}`)}> <Button size="sm" variant="outline" className="h-8 text-[12px] gap-1.5" onClick={() => navigate(`/dashboard/owner-updates?${ownerActionParams.toString()}`)}>
<StickyNote className="w-3.5 h-3.5" /> Note <StickyNote className="w-3.5 h-3.5" /> Note
</Button> </Button>
<RvRentalVacateButton ownerId={owner.id} />
</div> </div>
</div> </div>
</div> </div>
+46 -4
View File
@@ -56,6 +56,8 @@ export default function RVBoatLotsPage() {
const [lots, setLots] = useState<Lot[]>([]); const [lots, setLots] = useState<Lot[]>([]);
const [rentals, setRentals] = useState<Rental[]>([]); const [rentals, setRentals] = useState<Rental[]>([]);
const [waitlist, setWaitlist] = useState<WaitlistEntry[]>([]); const [waitlist, setWaitlist] = useState<WaitlistEntry[]>([]);
const [owners, setOwners] = useState<any[]>([]);
const [units, setUnits] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// dialogs // dialogs
@@ -68,7 +70,8 @@ export default function RVBoatLotsPage() {
const [lotForm, setLotForm] = useState({ lot_number: "", lot_type: "rv", size: "", monthly_rate: "", notes: "" }); const [lotForm, setLotForm] = useState({ lot_number: "", lot_type: "rv", size: "", monthly_rate: "", notes: "" });
const [rentalForm, setRentalForm] = useState({ const [rentalForm, setRentalForm] = useState({
lot_id: "", renter_name: "", renter_email: "", renter_phone: "", lot_id: "", renter_name: "", renter_email: "", renter_phone: "",
vehicle_description: "", start_date: new Date().toISOString().slice(0, 10), monthly_rate: "", notes: "" vehicle_description: "", start_date: new Date().toISOString().slice(0, 10), monthly_rate: "", notes: "",
owner_id: "", unit_id: ""
}); });
const [waitlistForm, setWaitlistForm] = useState({ const [waitlistForm, setWaitlistForm] = useState({
requester_name: "", requester_email: "", requester_phone: "", requester_name: "", requester_email: "", requester_phone: "",
@@ -100,10 +103,12 @@ export default function RVBoatLotsPage() {
if (!associationId) return; if (!associationId) return;
setLoading(true); setLoading(true);
try { try {
const [lotsRes, rentalsRes, wlRes] = await Promise.all([ 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_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_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("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 (lotsRes.error) throw lotsRes.error;
if (rentalsRes.error) throw rentalsRes.error; if (rentalsRes.error) throw rentalsRes.error;
@@ -111,6 +116,8 @@ export default function RVBoatLotsPage() {
setLots(lotsRes.data || []); setLots(lotsRes.data || []);
setRentals(rentalsRes.data || []); setRentals(rentalsRes.data || []);
setWaitlist(wlRes.data || []); setWaitlist(wlRes.data || []);
setOwners(ownersRes.data || []);
setUnits(unitsRes.data || []);
} catch (e: any) { } catch (e: any) {
toast.error(e.message || "Failed to load data"); toast.error(e.message || "Failed to load data");
} finally { } finally {
@@ -204,12 +211,14 @@ export default function RVBoatLotsPage() {
start_date: rentalForm.start_date, start_date: rentalForm.start_date,
monthly_rate: rentalForm.monthly_rate ? Number(rentalForm.monthly_rate) : null, monthly_rate: rentalForm.monthly_rate ? Number(rentalForm.monthly_rate) : null,
notes: rentalForm.notes || null, notes: rentalForm.notes || null,
owner_id: rentalForm.owner_id || null,
unit_id: rentalForm.unit_id || null,
status: "active", status: "active",
}); });
if (error) { toast.error(error.message); return; } if (error) { toast.error(error.message); return; }
toast.success("Rental created"); toast.success("Rental created");
setRentalDialogOpen(false); setRentalDialogOpen(false);
setRentalForm({ lot_id: "", renter_name: "", renter_email: "", renter_phone: "", vehicle_description: "", start_date: new Date().toISOString().slice(0, 10), monthly_rate: "", notes: "" }); 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(); load();
}; };
@@ -546,6 +555,38 @@ export default function RVBoatLotsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Link to Owner</Label>
<Select value={rentalForm.owner_id || "none"} onValueChange={v => {
if (v === "none") { setRentalForm(f => ({ ...f, owner_id: "" })); return; }
const o = owners.find(x => x.id === v);
setRentalForm(f => ({
...f, owner_id: v,
unit_id: o?.unit_id || f.unit_id,
renter_name: f.renter_name || `${o?.first_name || ""} ${o?.last_name || ""}`.trim(),
renter_email: f.renter_email || o?.email || "",
renter_phone: f.renter_phone || o?.phone || "",
}));
}}>
<SelectTrigger><SelectValue placeholder="Optional" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{owners.map(o => <SelectItem key={o.id} value={o.id}>{`${o.last_name || ""}, ${o.first_name || ""}`.replace(/^, |, $/g, "").trim() || "Unnamed"}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>Link to Unit</Label>
<Select value={rentalForm.unit_id || "none"} onValueChange={v => setRentalForm(f => ({ ...f, unit_id: v === "none" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="Optional" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{units.map(u => <SelectItem key={u.id} value={u.id}>Unit {u.unit_number}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div><Label>Renter name *</Label><Input value={rentalForm.renter_name} onChange={e => setRentalForm({ ...rentalForm, renter_name: e.target.value })} /></div> <div><Label>Renter name *</Label><Input value={rentalForm.renter_name} onChange={e => setRentalForm({ ...rentalForm, renter_name: e.target.value })} /></div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div><Label>Email</Label><Input type="email" value={rentalForm.renter_email} onChange={e => setRentalForm({ ...rentalForm, renter_email: e.target.value })} /></div> <div><Label>Email</Label><Input type="email" value={rentalForm.renter_email} onChange={e => setRentalForm({ ...rentalForm, renter_email: e.target.value })} /></div>
@@ -566,7 +607,7 @@ export default function RVBoatLotsPage() {
</Dialog> </Dialog>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{(() => { const activeRentals = rentals.filter(r => r.status === "active"); return ( {(() => { const activeRentals = rentals.filter(r => r.status === "active" || r.status === "vacating"); return (
<> <>
{selectedRentalIds.size > 0 && ( {selectedRentalIds.size > 0 && (
<div className="flex items-center justify-between rounded-md border bg-muted/40 px-3 py-2 mb-3"> <div className="flex items-center justify-between rounded-md border bg-muted/40 px-3 py-2 mb-3">
@@ -597,6 +638,7 @@ export default function RVBoatLotsPage() {
<Badge variant={r.is_owner ? "default" : "secondary"} className="text-[10px]"> <Badge variant={r.is_owner ? "default" : "secondary"} className="text-[10px]">
{r.is_owner ? "Owner" : "Renter"} {r.is_owner ? "Owner" : "Renter"}
</Badge> </Badge>
{r.status === "vacating" && <Badge variant="outline" className="text-[10px] border-amber-300 text-amber-700">Vacating</Badge>}
</div> </div>
<div className="text-xs text-muted-foreground">{r.renter_email}</div> <div className="text-xs text-muted-foreground">{r.renter_email}</div>
</TableCell> </TableCell>
+2
View File
@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input";
import { ArrowLeft, Home, Users, KeyRound, AlertTriangle, FileText, Settings, History, MessageSquare, PenTool, ImagePlus, Check, X, BarChart3, Play, RefreshCw, ArrowUpDown, FolderOpen, Clock } from "lucide-react"; import { ArrowLeft, Home, Users, KeyRound, AlertTriangle, FileText, Settings, History, MessageSquare, PenTool, ImagePlus, Check, X, BarChart3, Play, RefreshCw, ArrowUpDown, FolderOpen, Clock } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import PropertyImage from "@/components/PropertyImage"; import PropertyImage from "@/components/PropertyImage";
import RvRentalVacateButton from "@/components/RvRentalVacateButton";
import OwnersTab from "@/components/unit-profile/OwnersTab"; import OwnersTab from "@/components/unit-profile/OwnersTab";
import TenantsTab from "@/components/unit-profile/TenantsTab"; import TenantsTab from "@/components/unit-profile/TenantsTab";
import UnitViolationsTab from "@/components/unit-profile/UnitViolationsTab"; import UnitViolationsTab from "@/components/unit-profile/UnitViolationsTab";
@@ -180,6 +181,7 @@ export default function UnitProfilePage() {
<Button variant="outline" size="sm" className="text-xs gap-1.5" onClick={() => setWorkflowModalOpen(true)}> <Button variant="outline" size="sm" className="text-xs gap-1.5" onClick={() => setWorkflowModalOpen(true)}>
<Play className="h-3.5 w-3.5" /> Apply Workflow <Play className="h-3.5 w-3.5" /> Apply Workflow
</Button> </Button>
<RvRentalVacateButton unitId={unit.id} />
</div> </div>
</div> </div>