mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user