RV/Boat Lots: internal lot map with directory-unit links

Add a Map tab that reuses the reservation-map pin picker. Staff drop/label
pins per lot and optionally link a directory unit (pin.linked_amenity_id =
unit id). Config persists per association in rv_boat_lot_maps.

GoogleMapPicker generalized with optional linkLabel + allowLinkAnyStatus
(defaults preserve the amenity reservation-map behavior).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 20:40:52 -04:00
parent 10a9d31b94
commit 0c0300efce
3 changed files with 78 additions and 4 deletions
@@ -24,9 +24,13 @@ interface Props {
formAmenities: { id: string; name: string }[]; formAmenities: { id: string; name: string }[];
lockedView?: { lat: number; lng: number; zoom: number } | null; lockedView?: { lat: number; lng: number; zoom: number } | null;
onLockedViewChange?: (v: { lat: number; lng: number; zoom: number } | null) => void; onLockedViewChange?: (v: { lat: number; lng: number; zoom: number } | null) => void;
/** Noun used in the pin-link dropdown ("form" by default, e.g. "unit"). */
linkLabel?: string;
/** When true, the link dropdown shows regardless of pin status. */
allowLinkAnyStatus?: boolean;
} }
export default function GoogleMapPicker({ pins, onChange, zoom = 16, onZoomChange, formAmenities, lockedView, onLockedViewChange }: Props) { export default function GoogleMapPicker({ pins, onChange, zoom = 16, onZoomChange, formAmenities, lockedView, onLockedViewChange, linkLabel = "form", allowLinkAnyStatus = false }: Props) {
const mapRef = useRef<HTMLDivElement>(null); const mapRef = useRef<HTMLDivElement>(null);
const mapInstance = useRef<L.Map | null>(null); const mapInstance = useRef<L.Map | null>(null);
const markersRef = useRef<L.Marker[]>([]); const markersRef = useRef<L.Marker[]>([]);
@@ -245,7 +249,7 @@ export default function GoogleMapPicker({ pins, onChange, zoom = 16, onZoomChang
</SelectContent> </SelectContent>
</Select> </Select>
{pin.status === "available" && formAmenities.length > 0 && ( {(allowLinkAnyStatus || pin.status === "available") && formAmenities.length > 0 && (
<Select <Select
value={pin.linked_amenity_id || "none"} value={pin.linked_amenity_id || "none"}
onValueChange={v => updatePin(pin.id, { linked_amenity_id: v === "none" ? undefined : v })} onValueChange={v => updatePin(pin.id, { linked_amenity_id: v === "none" ? undefined : v })}
@@ -253,11 +257,11 @@ export default function GoogleMapPicker({ pins, onChange, zoom = 16, onZoomChang
<SelectTrigger className="text-sm flex-1 h-8"> <SelectTrigger className="text-sm flex-1 h-8">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<LinkIcon className="h-3 w-3" /> <LinkIcon className="h-3 w-3" />
<SelectValue placeholder="Link a form..." /> <SelectValue placeholder={`Link a ${linkLabel}...`} />
</div> </div>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">No linked form</SelectItem> <SelectItem value="none">No linked {linkLabel}</SelectItem>
{formAmenities.map(fa => ( {formAmenities.map(fa => (
<SelectItem key={fa.id} value={fa.id}>{fa.name}</SelectItem> <SelectItem key={fa.id} value={fa.id}>{fa.name}</SelectItem>
))} ))}
+54
View File
@@ -13,6 +13,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
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 { Checkbox } from "@/components/ui/checkbox";
import GoogleMapPicker, { type MapPinData } from "@/components/association/GoogleMapPicker";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus, Trash2, Check, ArrowUp, ArrowDown, Copy, Pencil, X } from "lucide-react"; import { Plus, Trash2, Check, ArrowUp, ArrowDown, Copy, Pencil, X } from "lucide-react";
@@ -91,6 +92,12 @@ export default function RVBoatLotsPage() {
const [savingRentalBulk, setSavingRentalBulk] = useState(false); const [savingRentalBulk, setSavingRentalBulk] = useState(false);
const [rentalBulk, setRentalBulk] = useState({ status: "no_change", monthly_rate: "", is_owner: "no_change" }); const [rentalBulk, setRentalBulk] = useState({ status: "no_change", monthly_rate: "", is_owner: "no_change" });
// internal map state
const [mapPins, setMapPins] = useState<MapPinData[]>([]);
const [mapZoom, setMapZoom] = useState<number>(17);
const [mapLockedView, setMapLockedView] = useState<{ lat: number; lng: number; zoom: number } | null>(null);
const [savingMap, setSavingMap] = useState(false);
const toggleSet = (setter: React.Dispatch<React.SetStateAction<Set<string>>>, id: string) => 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; }); 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) => const toggleSetMany = (setter: React.Dispatch<React.SetStateAction<Set<string>>>, ids: string[], checked: boolean) =>
@@ -120,6 +127,11 @@ export default function RVBoatLotsPage() {
setWaitlist(wlRes.data || []); setWaitlist(wlRes.data || []);
setOwners(ownersRes.data || []); setOwners(ownersRes.data || []);
setUnits(unitsRes.data || []); setUnits(unitsRes.data || []);
const { data: mapRow } = await supabase.from("rv_boat_lot_maps").select("config").eq("association_id", associationId).maybeSingle();
const cfg = (mapRow?.config as any) || {};
setMapPins(Array.isArray(cfg.pins) ? cfg.pins : []);
setMapZoom(typeof cfg.zoom === "number" ? cfg.zoom : 17);
setMapLockedView(cfg.locked_view && typeof cfg.locked_view.lat === "number" ? cfg.locked_view : null);
} catch (e: any) { } catch (e: any) {
toast.error(e.message || "Failed to load data"); toast.error(e.message || "Failed to load data");
} finally { } finally {
@@ -253,6 +265,19 @@ export default function RVBoatLotsPage() {
} }
}; };
const saveMap = async () => {
if (!associationId) return;
setSavingMap(true);
const { error } = await supabase.from("rv_boat_lot_maps").upsert({
association_id: associationId,
config: { pins: mapPins, zoom: mapZoom, locked_view: mapLockedView },
updated_at: new Date().toISOString(),
}, { onConflict: "association_id" });
setSavingMap(false);
if (error) { toast.error(error.message); return; }
toast.success("Map saved");
};
const requestInsurance = async (r: Rental) => { const requestInsurance = async (r: Rental) => {
if (!r.renter_email) { toast.error("Add a renter email first, then request insurance."); return; } if (!r.renter_email) { toast.error("Add a renter email first, then request insurance."); return; }
try { try {
@@ -385,6 +410,7 @@ export default function RVBoatLotsPage() {
<TabsTrigger value="waitlist">Waitlist ({waitingEntries.length})</TabsTrigger> <TabsTrigger value="waitlist">Waitlist ({waitingEntries.length})</TabsTrigger>
<TabsTrigger value="lots">Lots ({lots.length})</TabsTrigger> <TabsTrigger value="lots">Lots ({lots.length})</TabsTrigger>
<TabsTrigger value="rentals">Active Rentals ({rentals.filter(r => r.status === "active").length})</TabsTrigger> <TabsTrigger value="rentals">Active Rentals ({rentals.filter(r => r.status === "active").length})</TabsTrigger>
<TabsTrigger value="map">Map</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger> <TabsTrigger value="history">History</TabsTrigger>
</TabsList> </TabsList>
@@ -711,6 +737,34 @@ export default function RVBoatLotsPage() {
</Card> </Card>
</TabsContent> </TabsContent>
{/* Map */}
<TabsContent value="map" className="space-y-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Lot Map</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Click the map to drop a pin for each lot, label it, and optionally link a directory unit for reference. Internal use only.
</p>
</div>
<Button size="sm" onClick={saveMap} disabled={savingMap}>{savingMap ? "Saving…" : "Save Map"}</Button>
</CardHeader>
<CardContent>
<GoogleMapPicker
pins={mapPins}
onChange={setMapPins}
zoom={mapZoom}
onZoomChange={setMapZoom}
lockedView={mapLockedView}
onLockedViewChange={setMapLockedView}
linkLabel="unit"
allowLinkAnyStatus
formAmenities={units.map(u => ({ id: u.id, name: `Unit ${u.unit_number}` }))}
/>
</CardContent>
</Card>
</TabsContent>
{/* History */} {/* History */}
<TabsContent value="history" className="space-y-4"> <TabsContent value="history" className="space-y-4">
<Card> <Card>
@@ -0,0 +1,16 @@
-- Internal lot map for the RV/Boat Lots page: per-association pin layout
-- (config holds { pins, zoom, locked_view }); pins may link a directory unit.
create table if not exists public.rv_boat_lot_maps (
association_id uuid primary key references public.associations(id) on delete cascade,
config jsonb not null default '{}'::jsonb,
updated_at timestamptz default now()
);
alter table public.rv_boat_lot_maps enable row level security;
drop policy if exists "Staff manage rv boat lot maps" on public.rv_boat_lot_maps;
create policy "Staff manage rv boat lot maps"
on public.rv_boat_lot_maps for all
to authenticated
using (has_role(auth.uid(), 'admin'::app_role) or has_role(auth.uid(), 'manager'::app_role) or has_role(auth.uid(), 'association_management'::app_role))
with check (has_role(auth.uid(), 'admin'::app_role) or has_role(auth.uid(), 'manager'::app_role) or has_role(auth.uid(), 'association_management'::app_role));