mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -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}
|
||||||
|
|||||||
@@ -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,23 +816,38 @@ 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>
|
||||||
<GoogleMapPicker
|
{mapAmenities.length === 0 ? (
|
||||||
pins={mapPins}
|
<p className="text-sm text-muted-foreground py-6">
|
||||||
onChange={setMapPins}
|
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.
|
||||||
zoom={mapZoom}
|
</p>
|
||||||
onZoomChange={setMapZoom}
|
) : (
|
||||||
lockedView={mapLockedView}
|
<GoogleMapPicker
|
||||||
onLockedViewChange={setMapLockedView}
|
pins={mapPins}
|
||||||
linkLabel="unit"
|
onChange={setMapPins}
|
||||||
allowLinkAnyStatus
|
zoom={mapZoom}
|
||||||
formAmenities={units.map(u => ({ id: u.id, name: `Unit ${u.unit_number}` }))}
|
onZoomChange={setMapZoom}
|
||||||
/>
|
lockedView={mapLockedView}
|
||||||
|
onLockedViewChange={setMapLockedView}
|
||||||
|
linkLabel="unit"
|
||||||
|
allowLinkAnyStatus
|
||||||
|
showAmount
|
||||||
|
formAmenities={units.map(u => ({ id: u.id, name: `Unit ${u.unit_number}` }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user