mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -24,9 +24,13 @@ interface Props {
|
||||
formAmenities: { id: string; name: string }[];
|
||||
lockedView?: { lat: number; lng: number; zoom: number } | null;
|
||||
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 mapInstance = useRef<L.Map | null>(null);
|
||||
const markersRef = useRef<L.Marker[]>([]);
|
||||
@@ -245,7 +249,7 @@ export default function GoogleMapPicker({ pins, onChange, zoom = 16, onZoomChang
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{pin.status === "available" && formAmenities.length > 0 && (
|
||||
{(allowLinkAnyStatus || pin.status === "available") && formAmenities.length > 0 && (
|
||||
<Select
|
||||
value={pin.linked_amenity_id || "none"}
|
||||
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">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LinkIcon className="h-3 w-3" />
|
||||
<SelectValue placeholder="Link a form..." />
|
||||
<SelectValue placeholder={`Link a ${linkLabel}...`} />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No linked form</SelectItem>
|
||||
<SelectItem value="none">No linked {linkLabel}</SelectItem>
|
||||
{formAmenities.map(fa => (
|
||||
<SelectItem key={fa.id} value={fa.id}>{fa.name}</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||
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 GoogleMapPicker, { type MapPinData } from "@/components/association/GoogleMapPicker";
|
||||
import { toast } from "sonner";
|
||||
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 [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) =>
|
||||
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) =>
|
||||
@@ -120,6 +127,11 @@ 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);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || "Failed to load data");
|
||||
} 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) => {
|
||||
if (!r.renter_email) { toast.error("Add a renter email first, then request insurance."); return; }
|
||||
try {
|
||||
@@ -385,6 +410,7 @@ export default function RVBoatLotsPage() {
|
||||
<TabsTrigger value="waitlist">Waitlist ({waitingEntries.length})</TabsTrigger>
|
||||
<TabsTrigger value="lots">Lots ({lots.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>
|
||||
</TabsList>
|
||||
|
||||
@@ -711,6 +737,34 @@ export default function RVBoatLotsPage() {
|
||||
</Card>
|
||||
</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 */}
|
||||
<TabsContent value="history" className="space-y-4">
|
||||
<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));
|
||||
Reference in New Issue
Block a user