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; lng: string;
status: "available" | "unavailable"; status: "available" | "unavailable";
linked_amenity_id?: string; linked_amenity_id?: string;
amount?: number;
} }
interface Props { interface Props {
@@ -28,9 +29,11 @@ interface Props {
linkLabel?: string; linkLabel?: string;
/** When true, the link dropdown shows regardless of pin status. */ /** When true, the link dropdown shows regardless of pin status. */
allowLinkAnyStatus?: boolean; 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 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[]>([]);
@@ -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"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{pin.lat}, {pin.lng}</span> <span>{pin.lat}, {pin.lng}</span>
</div> </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"> <div className="flex items-center gap-2 flex-wrap">
<Select <Select
value={pin.status} value={pin.status}
+46 -15
View File
@@ -105,11 +105,20 @@ 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 // 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 [mapPins, setMapPins] = useState<MapPinData[]>([]);
const [mapZoom, setMapZoom] = useState<number>(17); const [mapZoom, setMapZoom] = useState<number>(17);
const [mapLockedView, setMapLockedView] = useState<{ lat: number; lng: number; zoom: number } | null>(null); const [mapLockedView, setMapLockedView] = useState<{ lat: number; lng: number; zoom: number } | null>(null);
const [savingMap, setSavingMap] = useState(false); 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 [syncingPublic, setSyncingPublic] = useState(false);
const toggleSet = (setter: React.Dispatch<React.SetStateAction<Set<string>>>, id: string) => const toggleSet = (setter: React.Dispatch<React.SetStateAction<Set<string>>>, id: string) =>
@@ -141,11 +150,16 @@ 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 { data: mapAms } = await (supabase as any).from("amenities")
const cfg = (mapRow?.config as any) || {}; .select("id, name, map_config")
setMapPins(Array.isArray(cfg.pins) ? cfg.pins : []); .eq("association_id", associationId)
setMapZoom(typeof cfg.zoom === "number" ? cfg.zoom : 17); .eq("amenity_type", "map")
setMapLockedView(cfg.locked_view && typeof cfg.locked_view.lat === "number" ? cfg.locked_view : null); .eq("is_active", true)
.order("sort_order");
const amList = mapAms || [];
setMapAmenities(amList);
setSelectedMapAmenityId(amList[0]?.id || "");
applyAmenityToMap(amList[0]);
} catch (e: any) { } catch (e: any) {
toast.error(e.message || "Failed to load data"); toast.error(e.message || "Failed to load data");
} finally { } finally {
@@ -328,16 +342,18 @@ export default function RVBoatLotsPage() {
}; };
const saveMap = async () => { const saveMap = async () => {
if (!associationId) return; if (!selectedMapAmenityId) { toast.error("No map amenity selected"); return; }
setSavingMap(true); setSavingMap(true);
const { error } = await supabase.from("rv_boat_lot_maps").upsert({ const amenity = mapAmenities.find(a => a.id === selectedMapAmenityId);
association_id: associationId, const base = (amenity?.map_config as any) || {};
config: { pins: mapPins, zoom: mapZoom, locked_view: mapLockedView }, const nextConfig = { ...base, pins: mapPins, zoom: mapZoom, locked_view: mapLockedView };
updated_at: new Date().toISOString(), const { error } = await (supabase as any).from("amenities")
}, { onConflict: "association_id" }); .update({ map_config: nextConfig, updated_at: new Date().toISOString() })
.eq("id", selectedMapAmenityId);
setSavingMap(false); setSavingMap(false);
if (error) { toast.error(error.message); return; } 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) => { const requestInsurance = async (r: Rental) => {
@@ -800,12 +816,25 @@ export default function RVBoatLotsPage() {
<div> <div>
<CardTitle>Lot Map</CardTitle> <CardTitle>Lot Map</CardTitle>
<p className="text-sm text-muted-foreground mt-1"> <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> </p>
</div> </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> </CardHeader>
<CardContent> <CardContent>
{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 <GoogleMapPicker
pins={mapPins} pins={mapPins}
onChange={setMapPins} onChange={setMapPins}
@@ -815,8 +844,10 @@ export default function RVBoatLotsPage() {
onLockedViewChange={setMapLockedView} onLockedViewChange={setMapLockedView}
linkLabel="unit" linkLabel="unit"
allowLinkAnyStatus allowLinkAnyStatus
showAmount
formAmenities={units.map(u => ({ id: u.id, name: `Unit ${u.unit_number}` }))} formAmenities={units.map(u => ({ id: u.id, name: `Unit ${u.unit_number}` }))}
/> />
)}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>