mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50: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 { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import RvRentalVacateButton from "@/components/RvRentalVacateButton";
|
||||
import {
|
||||
ArrowLeft, Mail, Phone, MapPin, Send, AlertTriangle, StickyNote,
|
||||
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()}`)}>
|
||||
<StickyNote className="w-3.5 h-3.5" /> Note
|
||||
</Button>
|
||||
<RvRentalVacateButton ownerId={owner.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,8 @@ export default function RVBoatLotsPage() {
|
||||
const [lots, setLots] = useState<Lot[]>([]);
|
||||
const [rentals, setRentals] = useState<Rental[]>([]);
|
||||
const [waitlist, setWaitlist] = useState<WaitlistEntry[]>([]);
|
||||
const [owners, setOwners] = useState<any[]>([]);
|
||||
const [units, setUnits] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// dialogs
|
||||
@@ -68,7 +70,8 @@ export default function RVBoatLotsPage() {
|
||||
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: ""
|
||||
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: "",
|
||||
@@ -100,10 +103,12 @@ export default function RVBoatLotsPage() {
|
||||
if (!associationId) return;
|
||||
setLoading(true);
|
||||
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_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;
|
||||
@@ -111,6 +116,8 @@ export default function RVBoatLotsPage() {
|
||||
setLots(lotsRes.data || []);
|
||||
setRentals(rentalsRes.data || []);
|
||||
setWaitlist(wlRes.data || []);
|
||||
setOwners(ownersRes.data || []);
|
||||
setUnits(unitsRes.data || []);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || "Failed to load data");
|
||||
} finally {
|
||||
@@ -204,12 +211,14 @@ export default function RVBoatLotsPage() {
|
||||
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: "" });
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -546,6 +555,38 @@ export default function RVBoatLotsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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 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>
|
||||
@@ -566,7 +607,7 @@ export default function RVBoatLotsPage() {
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<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 && (
|
||||
<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]">
|
||||
{r.is_owner ? "Owner" : "Renter"}
|
||||
</Badge>
|
||||
{r.status === "vacating" && <Badge variant="outline" className="text-[10px] border-amber-300 text-amber-700">Vacating</Badge>}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{r.renter_email}</div>
|
||||
</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 { useToast } from "@/hooks/use-toast";
|
||||
import PropertyImage from "@/components/PropertyImage";
|
||||
import RvRentalVacateButton from "@/components/RvRentalVacateButton";
|
||||
import OwnersTab from "@/components/unit-profile/OwnersTab";
|
||||
import TenantsTab from "@/components/unit-profile/TenantsTab";
|
||||
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)}>
|
||||
<Play className="h-3.5 w-3.5" /> Apply Workflow
|
||||
</Button>
|
||||
<RvRentalVacateButton unitId={unit.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user