mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -12,8 +12,9 @@ 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 { 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 = {
|
||||
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: ""
|
||||
});
|
||||
|
||||
// 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(() => {
|
||||
if (!associationId) return "";
|
||||
return `${window.location.origin}/rv-boat-waitlist/${associationId}`;
|
||||
@@ -128,6 +145,51 @@ export default function RVBoatLotsPage() {
|
||||
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 () => {
|
||||
if (!associationId || !rentalForm.lot_id || !rentalForm.renter_name.trim()) {
|
||||
toast.error("Lot and renter name required"); return;
|
||||
@@ -420,15 +482,27 @@ export default function RVBoatLotsPage() {
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<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> : (
|
||||
<Table>
|
||||
<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>Rate</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{lots.map(lot => (
|
||||
<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><Badge variant="outline">{lot.lot_type}</Badge></TableCell>
|
||||
<TableCell>{lot.size || "-"}</TableCell>
|
||||
@@ -492,17 +566,30 @@ export default function RVBoatLotsPage() {
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<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>
|
||||
<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>Start</TableHead><TableHead>Rate</TableHead><TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{rentals.filter(r => r.status === "active").map(r => {
|
||||
{activeRentals.map(r => {
|
||||
const lot = lots.find(l => l.id === r.lot_id);
|
||||
return (
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -530,6 +617,8 @@ export default function RVBoatLotsPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
); })()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -581,6 +670,86 @@ export default function RVBoatLotsPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user