RV/Boat Lots: map tab edits the association Map amenity + per-pin amount

The Map tab now binds to the association's active map-type amenity (the
reservation map) instead of a separate table — pins edited here are the public
reservation map. Adds a per-pin amount ($/mo) field (GoogleMapPicker showAmount
prop, backward-compatible). Shows a notice when no Map amenity is active and an
amenity picker when more than one exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 22:41:27 -04:00
parent 5a7f21fee6
commit a81e4f51ab
2 changed files with 76 additions and 27 deletions
+19 -1
View File
@@ -14,6 +14,7 @@ export interface MapPinData {
lng: string;
status: "available" | "unavailable";
linked_amenity_id?: string;
amount?: number;
}
interface Props {
@@ -28,9 +29,11 @@ interface Props {
linkLabel?: string;
/** When true, the link dropdown shows regardless of pin status. */
allowLinkAnyStatus?: boolean;
/** When true, each pin gets a per-pin amount ($) field. */
showAmount?: boolean;
}
export default function GoogleMapPicker({ pins, onChange, zoom = 16, onZoomChange, formAmenities, lockedView, onLockedViewChange, linkLabel = "form", allowLinkAnyStatus = false }: Props) {
export default function GoogleMapPicker({ pins, onChange, zoom = 16, onZoomChange, formAmenities, lockedView, onLockedViewChange, linkLabel = "form", allowLinkAnyStatus = false, showAmount = false }: Props) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstance = useRef<L.Map | null>(null);
const markersRef = useRef<L.Marker[]>([]);
@@ -237,6 +240,21 @@ export default function GoogleMapPicker({ pins, onChange, zoom = 16, onZoomChang
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{pin.lat}, {pin.lng}</span>
</div>
{showAmount && (
<div className="flex items-center gap-1.5">
<span className="text-sm text-muted-foreground">$</span>
<Input
type="number"
min="0"
step="0.01"
placeholder="Amount"
value={pin.amount ?? ""}
onChange={e => updatePin(pin.id, { amount: e.target.value === "" ? undefined : Number(e.target.value) })}
className="text-sm h-8 w-[130px]"
/>
<span className="text-xs text-muted-foreground">/ mo</span>
</div>
)}
<div className="flex items-center gap-2 flex-wrap">
<Select
value={pin.status}
+57 -26
View File
@@ -105,11 +105,20 @@ export default function RVBoatLotsPage() {
const [savingRentalBulk, setSavingRentalBulk] = useState(false);
const [rentalBulk, setRentalBulk] = useState({ status: "no_change", monthly_rate: "", is_owner: "no_change" });
// internal map state
// map state — bound to the association's active map amenity
const [mapAmenities, setMapAmenities] = useState<any[]>([]);
const [selectedMapAmenityId, setSelectedMapAmenityId] = useState<string>("");
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 applyAmenityToMap = (amenity: any) => {
const cfg = (amenity?.map_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);
};
const [syncingPublic, setSyncingPublic] = useState(false);
const toggleSet = (setter: React.Dispatch<React.SetStateAction<Set<string>>>, id: string) =>
@@ -141,11 +150,16 @@ export default function RVBoatLotsPage() {
setWaitlist(wlRes.data || []);
setOwners(ownersRes.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);
const { data: mapAms } = await (supabase as any).from("amenities")
.select("id, name, map_config")
.eq("association_id", associationId)
.eq("amenity_type", "map")
.eq("is_active", true)
.order("sort_order");
const amList = mapAms || [];
setMapAmenities(amList);
setSelectedMapAmenityId(amList[0]?.id || "");
applyAmenityToMap(amList[0]);
} catch (e: any) {
toast.error(e.message || "Failed to load data");
} finally {
@@ -328,16 +342,18 @@ export default function RVBoatLotsPage() {
};
const saveMap = async () => {
if (!associationId) return;
if (!selectedMapAmenityId) { toast.error("No map amenity selected"); 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" });
const amenity = mapAmenities.find(a => a.id === selectedMapAmenityId);
const base = (amenity?.map_config as any) || {};
const nextConfig = { ...base, pins: mapPins, zoom: mapZoom, locked_view: mapLockedView };
const { error } = await (supabase as any).from("amenities")
.update({ map_config: nextConfig, updated_at: new Date().toISOString() })
.eq("id", selectedMapAmenityId);
setSavingMap(false);
if (error) { toast.error(error.message); return; }
toast.success("Map saved");
setMapAmenities(prev => prev.map(a => a.id === selectedMapAmenityId ? { ...a, map_config: nextConfig } : a));
toast.success("Map saved to amenity");
};
const requestInsurance = async (r: Rental) => {
@@ -800,23 +816,38 @@ export default function RVBoatLotsPage() {
<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.
Edit the association's Map amenity: drop a pin per lot, set its amount, and optionally link a directory unit. These pins are the public reservation map.
</p>
</div>
<Button size="sm" onClick={saveMap} disabled={savingMap}>{savingMap ? "Saving…" : "Save Map"}</Button>
<div className="flex items-center gap-2">
{mapAmenities.length > 1 && (
<Select value={selectedMapAmenityId} onValueChange={(v) => { setSelectedMapAmenityId(v); applyAmenityToMap(mapAmenities.find(a => a.id === v)); }}>
<SelectTrigger className="w-[200px]"><SelectValue /></SelectTrigger>
<SelectContent>{mapAmenities.map(a => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}</SelectContent>
</Select>
)}
<Button size="sm" onClick={saveMap} disabled={savingMap || !selectedMapAmenityId}>{savingMap ? "Saving…" : "Save Map"}</Button>
</div>
</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}` }))}
/>
{mapAmenities.length === 0 ? (
<p className="text-sm text-muted-foreground py-6">
No active Map amenity for this association. Create or activate a <span className="font-medium">Map</span> amenity in the association's Amenities, then manage its lot pins here.
</p>
) : (
<GoogleMapPicker
pins={mapPins}
onChange={setMapPins}
zoom={mapZoom}
onZoomChange={setMapZoom}
lockedView={mapLockedView}
onLockedViewChange={setMapLockedView}
linkLabel="unit"
allowLinkAnyStatus
showAmount
formAmenities={units.map(u => ({ id: u.id, name: `Unit ${u.unit_number}` }))}
/>
)}
</CardContent>
</Card>
</TabsContent>