mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50: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 }[];
|
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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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));
|
||||||
Reference in New Issue
Block a user