RV/Boat Lots: batch edit Lots and Active Rentals

Add row checkboxes + select-all to the Lots and Active Rentals tables with a
selection bar. Bulk edit lots (type, status, monthly rate) and bulk delete;
bulk edit rentals (status incl. end, owner/renter flag, monthly rate).
Phase 1 of the RV/Boat lots enhancements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 17:54:43 -04:00
parent 4ecbdcfd4d
commit 7c18576390
+172 -3
View File
@@ -12,8 +12,9 @@ import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus, Trash2, Check, ArrowUp, ArrowDown, Copy } from "lucide-react"; import { Plus, Trash2, Check, ArrowUp, ArrowDown, Copy, Pencil, X } from "lucide-react";
type Lot = { type Lot = {
id: string; association_id: string; lot_number: string; lot_type: string; id: string; association_id: string; lot_number: string; lot_type: string;
@@ -74,6 +75,22 @@ export default function RVBoatLotsPage() {
unit_address: "", requested_lot_type: "either", vehicle_description: "", notes: "" unit_address: "", requested_lot_type: "either", vehicle_description: "", notes: ""
}); });
// batch edit state
const [selectedLotIds, setSelectedLotIds] = useState<Set<string>>(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<Set<string>>(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" });
const toggleSet = (setter: React.Dispatch<React.SetStateAction<Set<string>>>, 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<React.SetStateAction<Set<string>>>, 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(() => { const publicUrl = useMemo(() => {
if (!associationId) return ""; if (!associationId) return "";
return `${window.location.origin}/rv-boat-waitlist/${associationId}`; return `${window.location.origin}/rv-boat-waitlist/${associationId}`;
@@ -128,6 +145,51 @@ export default function RVBoatLotsPage() {
load(); load();
}; };
const applyLotBulk = async () => {
const ids = [...selectedLotIds];
if (!ids.length) return;
const patch: Record<string, any> = {};
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<string, any> = {};
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 () => { const createRental = async () => {
if (!associationId || !rentalForm.lot_id || !rentalForm.renter_name.trim()) { if (!associationId || !rentalForm.lot_id || !rentalForm.renter_name.trim()) {
toast.error("Lot and renter name required"); return; toast.error("Lot and renter name required"); return;
@@ -420,15 +482,27 @@ export default function RVBoatLotsPage() {
</Dialog> </Dialog>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{selectedLotIds.size > 0 && (
<div className="flex items-center justify-between rounded-md border bg-muted/40 px-3 py-2 mb-3">
<span className="text-sm font-medium">{selectedLotIds.size} selected</span>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => { setLotBulk({ lot_type: "no_change", status: "no_change", monthly_rate: "" }); setLotBulkOpen(true); }}><Pencil className="h-3.5 w-3.5 mr-1" /> Edit</Button>
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive" onClick={deleteLots}><Trash2 className="h-3.5 w-3.5 mr-1" /> Delete</Button>
<Button size="sm" variant="ghost" onClick={() => setSelectedLotIds(new Set())}><X className="h-3.5 w-3.5 mr-1" /> Clear</Button>
</div>
</div>
)}
{lots.length === 0 ? <p className="text-sm text-muted-foreground">No lots yet.</p> : ( {lots.length === 0 ? <p className="text-sm text-muted-foreground">No lots yet.</p> : (
<Table> <Table>
<TableHeader><TableRow> <TableHeader><TableRow>
<TableHead className="w-10"><Checkbox aria-label="Select all lots" checked={lots.length > 0 && lots.every(l => selectedLotIds.has(l.id))} onCheckedChange={(c) => toggleSetMany(setSelectedLotIds, lots.map(l => l.id), !!c)} /></TableHead>
<TableHead>Lot #</TableHead><TableHead>Type</TableHead><TableHead>Size</TableHead> <TableHead>Lot #</TableHead><TableHead>Type</TableHead><TableHead>Size</TableHead>
<TableHead>Rate</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Actions</TableHead> <TableHead>Rate</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Actions</TableHead>
</TableRow></TableHeader> </TableRow></TableHeader>
<TableBody> <TableBody>
{lots.map(lot => ( {lots.map(lot => (
<TableRow key={lot.id}> <TableRow key={lot.id}>
<TableCell><Checkbox aria-label={`Select lot ${lot.lot_number}`} checked={selectedLotIds.has(lot.id)} onCheckedChange={() => toggleSet(setSelectedLotIds, lot.id)} /></TableCell>
<TableCell className="font-medium">{lot.lot_number}</TableCell> <TableCell className="font-medium">{lot.lot_number}</TableCell>
<TableCell><Badge variant="outline">{lot.lot_type}</Badge></TableCell> <TableCell><Badge variant="outline">{lot.lot_type}</Badge></TableCell>
<TableCell>{lot.size || "-"}</TableCell> <TableCell>{lot.size || "-"}</TableCell>
@@ -492,17 +566,30 @@ export default function RVBoatLotsPage() {
</Dialog> </Dialog>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{rentals.filter(r => r.status === "active").length === 0 ? <p className="text-sm text-muted-foreground">No active rentals.</p> : ( {(() => { const activeRentals = rentals.filter(r => r.status === "active"); return (
<>
{selectedRentalIds.size > 0 && (
<div className="flex items-center justify-between rounded-md border bg-muted/40 px-3 py-2 mb-3">
<span className="text-sm font-medium">{selectedRentalIds.size} selected</span>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => { setRentalBulk({ status: "no_change", monthly_rate: "", is_owner: "no_change" }); setRentalBulkOpen(true); }}><Pencil className="h-3.5 w-3.5 mr-1" /> Edit</Button>
<Button size="sm" variant="ghost" onClick={() => setSelectedRentalIds(new Set())}><X className="h-3.5 w-3.5 mr-1" /> Clear</Button>
</div>
</div>
)}
{activeRentals.length === 0 ? <p className="text-sm text-muted-foreground">No active rentals.</p> : (
<Table> <Table>
<TableHeader><TableRow> <TableHeader><TableRow>
<TableHead className="w-10"><Checkbox aria-label="Select all rentals" checked={activeRentals.length > 0 && activeRentals.every(r => selectedRentalIds.has(r.id))} onCheckedChange={(c) => toggleSetMany(setSelectedRentalIds, activeRentals.map(r => r.id), !!c)} /></TableHead>
<TableHead>Lot</TableHead><TableHead>Renter</TableHead><TableHead>Vehicle</TableHead> <TableHead>Lot</TableHead><TableHead>Renter</TableHead><TableHead>Vehicle</TableHead>
<TableHead>Start</TableHead><TableHead>Rate</TableHead><TableHead className="text-right">Actions</TableHead> <TableHead>Start</TableHead><TableHead>Rate</TableHead><TableHead className="text-right">Actions</TableHead>
</TableRow></TableHeader> </TableRow></TableHeader>
<TableBody> <TableBody>
{rentals.filter(r => r.status === "active").map(r => { {activeRentals.map(r => {
const lot = lots.find(l => l.id === r.lot_id); const lot = lots.find(l => l.id === r.lot_id);
return ( return (
<TableRow key={r.id}> <TableRow key={r.id}>
<TableCell><Checkbox aria-label={`Select rental ${r.renter_name}`} checked={selectedRentalIds.has(r.id)} onCheckedChange={() => toggleSet(setSelectedRentalIds, r.id)} /></TableCell>
<TableCell className="font-medium">{lot?.lot_number || "—"}</TableCell> <TableCell className="font-medium">{lot?.lot_number || "—"}</TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -530,6 +617,8 @@ export default function RVBoatLotsPage() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
</>
); })()}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
@@ -581,6 +670,86 @@ export default function RVBoatLotsPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Bulk edit lots */}
<Dialog open={lotBulkOpen} onOpenChange={setLotBulkOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit {selectedLotIds.size} lot{selectedLotIds.size === 1 ? "" : "s"}</DialogTitle>
<DialogDescription>Only changed fields are applied. Leave a field on No change to keep existing values.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Type</Label>
<Select value={lotBulk.lot_type} onValueChange={v => setLotBulk({ ...lotBulk, lot_type: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="no_change">No change</SelectItem>
<SelectItem value="rv">RV</SelectItem>
<SelectItem value="boat">Boat</SelectItem>
<SelectItem value="either">Either</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Status</Label>
<Select value={lotBulk.status} onValueChange={v => setLotBulk({ ...lotBulk, status: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="no_change">No change</SelectItem>
<SelectItem value="available">Available</SelectItem>
<SelectItem value="occupied">Occupied</SelectItem>
<SelectItem value="maintenance">Maintenance</SelectItem>
</SelectContent>
</Select>
</div>
<div><Label>Monthly rate <span className="text-muted-foreground text-xs">(blank = no change)</span></Label><Input type="number" min="0" step="0.01" value={lotBulk.monthly_rate} onChange={e => setLotBulk({ ...lotBulk, monthly_rate: e.target.value })} placeholder="No change" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setLotBulkOpen(false)}>Cancel</Button>
<Button onClick={applyLotBulk} disabled={savingLotBulk}>Apply to {selectedLotIds.size}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk edit rentals */}
<Dialog open={rentalBulkOpen} onOpenChange={setRentalBulkOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit {selectedRentalIds.size} rental{selectedRentalIds.size === 1 ? "" : "s"}</DialogTitle>
<DialogDescription>Only changed fields are applied. Leave a field on No change to keep existing values.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Status</Label>
<Select value={rentalBulk.status} onValueChange={v => setRentalBulk({ ...rentalBulk, status: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="no_change">No change</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="ended">Ended (sets end date to today)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Owner / Renter</Label>
<Select value={rentalBulk.is_owner} onValueChange={v => setRentalBulk({ ...rentalBulk, is_owner: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="no_change">No change</SelectItem>
<SelectItem value="true">Owner</SelectItem>
<SelectItem value="false">Renter</SelectItem>
</SelectContent>
</Select>
</div>
<div><Label>Monthly rate <span className="text-muted-foreground text-xs">(blank = no change)</span></Label><Input type="number" min="0" step="0.01" value={rentalBulk.monthly_rate} onChange={e => setRentalBulk({ ...rentalBulk, monthly_rate: e.target.value })} placeholder="No change" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRentalBulkOpen(false)}>Cancel</Button>
<Button onClick={applyRentalBulk} disabled={savingRentalBulk}>Apply to {selectedRentalIds.size}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }