423 lines
18 KiB
React
423 lines
18 KiB
React
import React, { useState, useMemo, useEffect } from 'react';
|
|
import { MapContainer, TileLayer, Polygon, useMapEvents, Marker, Popup } from 'react-leaflet';
|
|
import { X, Home, DollarSign, User, Info, Edit2, Save, Check, MapPin, Loader2, Plus, AlertCircle, ShieldCheck } from 'lucide-react';
|
|
import 'leaflet/dist/leaflet.css';
|
|
import { useMockStore } from '../data/mockStore';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { SpotlightCard } from '../components/SpotlightCard';
|
|
import { useTheme } from '../context/ThemeContext';
|
|
import PropertyDetailDrawer from '../components/maps/PropertyDetailDrawer';
|
|
|
|
// --- ICONS ---
|
|
import { logger } from '../utils/logger';
|
|
import { toast }
|
|
from 'sonner';
|
|
import Loader from '../components/Loader';
|
|
|
|
// Fix for default Leaflet marker icons not showing up
|
|
import L from 'leaflet';
|
|
import icon from 'leaflet/dist/images/marker-icon.png';
|
|
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
|
|
|
|
let DefaultIcon = L.icon({
|
|
iconUrl: icon,
|
|
shadowUrl: iconShadow,
|
|
iconSize: [25, 41],
|
|
iconAnchor: [12, 41]
|
|
});
|
|
|
|
L.Marker.prototype.options.icon = DefaultIcon;
|
|
|
|
// --- COMPONENTS ---
|
|
|
|
const StatusBadge = ({ status }) => {
|
|
const colors = {
|
|
"Neutral": "bg-violet-400",
|
|
"Hot Lead": "bg-red-500",
|
|
"Renovated": "bg-blue-500",
|
|
"Customer": "bg-emerald-500",
|
|
"Not Interested": "bg-white border border-zinc-200 text-zinc-900 shadow-sm"
|
|
};
|
|
return (
|
|
<span className={`px-2 py-1 rounded text-[10px] uppercase font-bold text-white shadow-sm ${colors[status] || "bg-zinc-400"}`}>
|
|
{status}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// Map Interaction Component
|
|
const MapInteraction = ({ onMapClick }) => {
|
|
useMapEvents({
|
|
click(e) {
|
|
onMapClick(e.latlng);
|
|
},
|
|
});
|
|
return null;
|
|
};
|
|
|
|
const MapLegend = () => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
{/* Mobile Toggle - Top Right */}
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className="md:hidden absolute bottom-5 left-6 z-[2000] bg-white dark:bg-zinc-900 p-3 rounded-full shadow-xl border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-400"
|
|
>
|
|
<Info size={20} />
|
|
</button>
|
|
|
|
{/* Legend Content - Above Toggle on Mobile */}
|
|
<div className={`absolute bottom-[4.5rem] left-6 md:bottom-6 md:right-auto md:top-auto md:left-6 z-[2000] bg-white/95 dark:bg-black/95 backdrop-blur-md p-4 rounded-2xl border border-zinc-200 dark:border-white/10 shadow-xl transition-all duration-300 origin-bottom-left
|
|
${isOpen ? 'scale-100 opacity-100' : 'scale-0 opacity-0 md:scale-100 md:opacity-100'}
|
|
`}>
|
|
<h4 className="text-[10px] font-black uppercase tracking-widest text-zinc-500 mb-3 flex items-center justify-between">
|
|
Map Legend
|
|
<button onClick={() => setIsOpen(false)} className="md:hidden ml-4"><X size={14} /></button>
|
|
</h4>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 rounded-full bg-red-500 shadow-sm shadow-red-500/50"></div>
|
|
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-300">Hot Lead</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 rounded-full bg-emerald-500 shadow-sm shadow-emerald-500/50"></div>
|
|
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-300">Customer</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 rounded-full bg-blue-500 shadow-sm shadow-blue-500/50"></div>
|
|
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-300">Renovated</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 rounded-full bg-violet-400 shadow-sm shadow-violet-400/50"></div>
|
|
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-300">Neutral</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 rounded-full bg-white border-2 border-zinc-300 dark:border-zinc-500 shadow-sm"></div>
|
|
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-300">Not Interested</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
// Map Resizer Component
|
|
const MapResizer = () => {
|
|
const map = useMapEvents({});
|
|
|
|
useEffect(() => {
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
map.invalidateSize();
|
|
});
|
|
const container = map.getContainer();
|
|
resizeObserver.observe(container);
|
|
|
|
return () => {
|
|
resizeObserver.disconnect();
|
|
};
|
|
}, [map]);
|
|
|
|
return null;
|
|
};
|
|
|
|
// Map View
|
|
const MapView = ({ data, onSelect, onMapCreate }) => {
|
|
const mapStart = [33.0708, -96.7455];
|
|
const { theme } = useTheme(); // Get current theme
|
|
|
|
return (
|
|
<MapContainer center={mapStart} zoom={18} scrollWheelZoom={true} className="w-full h-full z-0 relative outline-none" style={{ height: "100%", width: "100%" }}>
|
|
|
|
<MapResizer />
|
|
|
|
{/*
|
|
- Light Mode: Standard vibrant OSM tiles (no filters)
|
|
- Dark Mode: Inverted, Grayscale, High Contrast for dark map
|
|
- Key prop forces remount on theme change to ensure tiles update immediately
|
|
*/}
|
|
<TileLayer
|
|
key={theme}
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
className={theme === 'dark'
|
|
? 'map-tiles filter invert grayscale contrast-100'
|
|
: 'map-tiles'
|
|
}
|
|
/>
|
|
|
|
<MapInteraction onMapClick={onMapCreate} />
|
|
|
|
{data.map((item) => {
|
|
let color = "#a78bfa"; // Violet-400 (New Neutral)
|
|
let fillOpacity = 0.2;
|
|
|
|
switch (item.canvassingStatus) {
|
|
case "Hot Lead":
|
|
color = "#ef4444"; // Red-500
|
|
fillOpacity = 0.4;
|
|
break;
|
|
case "Customer":
|
|
color = "#10b981"; // Emerald-500
|
|
fillOpacity = 0.3;
|
|
break;
|
|
case "Renovated":
|
|
color = "#3b82f6"; // Blue-500
|
|
break;
|
|
case "Not Interested":
|
|
color = "#ffffff"; // Bright White
|
|
fillOpacity = 0.4; // Higher opacity for white visibility
|
|
break;
|
|
default:
|
|
// Neutral (Violet)
|
|
break;
|
|
}
|
|
|
|
return (
|
|
<Polygon
|
|
key={item.id}
|
|
positions={item.polygon}
|
|
pathOptions={{
|
|
color: color,
|
|
fillColor: color,
|
|
fillOpacity: fillOpacity,
|
|
weight: 2
|
|
}}
|
|
eventHandlers={{
|
|
click: (e) => {
|
|
L.DomEvent.stopPropagation(e); // Prevent map click
|
|
onSelect(item);
|
|
},
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</MapContainer>
|
|
);
|
|
};
|
|
|
|
// --- MAIN PAGE COMPONENT --- //
|
|
|
|
function Maps() {
|
|
const { properties, setProperties, updatePropertyStatus } = useMockStore();
|
|
const { user } = useAuth();
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
// Simulate initial map load
|
|
const timer = setTimeout(() => setIsLoading(false), 1000);
|
|
return () => clearTimeout(timer);
|
|
}, []);
|
|
|
|
// Selection State
|
|
const [selectedPropertyId, setSelectedPropertyId] = useState(null);
|
|
const [newPropertyLocation, setNewPropertyLocation] = useState(null); // { lat, lng, address }
|
|
const [loadingAddr, setLoadingAddr] = useState(false);
|
|
|
|
// Derived Selection
|
|
const selectedProperty = useMemo(() =>
|
|
properties.find(p => p.id === selectedPropertyId),
|
|
[properties, selectedPropertyId]
|
|
);
|
|
|
|
// -- Handlers --
|
|
|
|
// 1. Click Existing Polygon
|
|
const handlePropertySelect = (property) => {
|
|
setNewPropertyLocation(null); // Clear new creation mode
|
|
setSelectedPropertyId(property.id);
|
|
};
|
|
|
|
// 2. Click Empty Map (Create New)
|
|
const handleMapCreate = async (latlng) => {
|
|
// Field Agents, Admins, and Owners can create new map entries
|
|
if (!['FIELD_AGENT', 'ADMIN', 'OWNER'].includes(user?.role)) return;
|
|
|
|
setSelectedPropertyId(null);
|
|
setNewPropertyLocation({ lat: latlng.lat, lng: latlng.lng, address: '' });
|
|
setLoadingAddr(true);
|
|
|
|
try {
|
|
// Reverse Geocode using simple OSM Nominatim API
|
|
// Note: In production, user should cache/throttle or use paid service.
|
|
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`);
|
|
|
|
if (!response.ok) throw new Error("Geocoding service unavailable");
|
|
|
|
const data = await response.json();
|
|
|
|
setNewPropertyLocation(prev => ({
|
|
...prev,
|
|
address: data.display_name || "Unknown Location"
|
|
}));
|
|
logger.info("Geocoding successful", { lat: latlng.lat, lng: latlng.lng });
|
|
} catch (err) {
|
|
logger.error("Geocoding failed", err);
|
|
toast.error("Could not fetch address", { description: "Using coordinates instead." });
|
|
setNewPropertyLocation(prev => ({ ...prev, address: "Location Found (Address Unavailable)" }));
|
|
} finally {
|
|
setLoadingAddr(false);
|
|
}
|
|
};
|
|
|
|
// 3. Save Changes (Create or Update)
|
|
const handleSave = (formData) => {
|
|
try {
|
|
// Construct the full data objects based on schema
|
|
const newOwnerData = {
|
|
fullName: formData.ownerName,
|
|
primaryPhoneNumber: formData.ownerPhone || "Pending",
|
|
emailAddress: formData.ownerEmail || "Pending",
|
|
details: {
|
|
occupation: formData.occupation,
|
|
ownershipType: formData.ownershipType,
|
|
willingToSell: formData.willingToSell,
|
|
desiredPrice: formData.desiredPrice,
|
|
minPrice: formData.minPrice,
|
|
ownerAddress: formData.ownerAddress
|
|
}
|
|
};
|
|
|
|
const newPropertyData = {
|
|
propertyType: formData.propertyType,
|
|
currentEstimatedMarketValue: parseInt(formData.marketValue) || 0,
|
|
notes: formData.notes,
|
|
details: {
|
|
builtUpArea: formData.builtUpArea,
|
|
lotSize: formData.lotSize,
|
|
yearBuilt: formData.yearBuilt,
|
|
bedrooms: formData.bedrooms,
|
|
bathrooms: formData.bathrooms,
|
|
parkingSpaces: formData.parkingSpaces,
|
|
taxValue: formData.taxValue,
|
|
roofCondition: formData.roofCondition,
|
|
lastRenovationDate: formData.lastRenovationDate,
|
|
|
|
// Rental Status (Enhanced)
|
|
isRented: formData.isRented === 'Yes',
|
|
currentlyRented: formData.isRented === 'Yes', // Sync both just in case
|
|
currentTenantName: formData.tenantName,
|
|
tenantPhone: formData.tenantPhone,
|
|
tenantEmail: formData.tenantEmail,
|
|
tenantOccupation: formData.tenantOccupation,
|
|
tenantEmployer: formData.tenantEmployer,
|
|
tenantAnnualIncome: formData.tenantIncome,
|
|
livingStatus: formData.livingStatus,
|
|
leaseType: formData.leaseType,
|
|
leaseSignedDate: formData.leaseSigned,
|
|
leaseStartDate: formData.leaseStart,
|
|
leaseEndDate: formData.leaseEnd,
|
|
currentMonthlyRentAmount: formData.currentRent,
|
|
|
|
ownerWillingToRent: formData.ownerWillingToRent === 'Yes',
|
|
expectedMonthlyRentAmount: formData.expectedRent,
|
|
|
|
// Location (can be expanded)
|
|
schoolDistrict: formData.schoolDistrict,
|
|
neighborhoodRating: formData.neighborhoodRating
|
|
}
|
|
};
|
|
|
|
const newInsuranceData = {
|
|
insurance_company: formData.insurance_company,
|
|
insurance_company_not_listed: formData.insurance_company_not_listed,
|
|
damage_location: formData.damage_location,
|
|
date_of_loss: formData.date_of_loss,
|
|
claim_filed: formData.claim_filed === 'Yes',
|
|
claim_number: formData.claim_number,
|
|
has_paperwork: formData.has_paperwork === 'Yes',
|
|
adjuster_name: formData.adjuster_name,
|
|
adjuster_phone: formData.adjuster_phone,
|
|
adjuster_ext: formData.adjuster_ext,
|
|
adjuster_type: formData.adjuster_type,
|
|
adjuster_fax: formData.adjuster_fax,
|
|
adjuster_email: formData.adjuster_email,
|
|
met_with_adjuster: formData.met_with_adjuster === 'Yes',
|
|
claim_approved: formData.claim_approved === 'Yes',
|
|
};
|
|
|
|
if (selectedPropertyId) {
|
|
// Update Existing
|
|
setProperties(prev => prev.map(p => {
|
|
if (p.id === selectedPropertyId) {
|
|
return {
|
|
...p,
|
|
canvassingStatus: formData.status,
|
|
ownerData: { ...p.ownerData, ...newOwnerData },
|
|
propertyData: { ...p.propertyData, ...newPropertyData.details, ...newPropertyData }, // Merge Flattened and Nested
|
|
insuranceData: { ...p.insuranceData, ...newInsuranceData }
|
|
};
|
|
}
|
|
return p;
|
|
}));
|
|
toast.success("Property updated successfully");
|
|
} else if (newPropertyLocation) {
|
|
// Create New
|
|
const newId = properties.length + 1;
|
|
// Generate Polygon (Simple Box around center) as placeholder
|
|
// In real app, user would draw. Here we just spawn a box.
|
|
const d = 0.0001;
|
|
const newPoly = [
|
|
[newPropertyLocation.lat + d, newPropertyLocation.lng - d],
|
|
[newPropertyLocation.lat + d, newPropertyLocation.lng + d],
|
|
[newPropertyLocation.lat - d, newPropertyLocation.lng + d],
|
|
[newPropertyLocation.lat - d, newPropertyLocation.lng - d],
|
|
];
|
|
|
|
const newItem = {
|
|
id: newId,
|
|
center: [newPropertyLocation.lat, newPropertyLocation.lng],
|
|
polygon: newPoly,
|
|
canvassingStatus: formData.status,
|
|
ownerData: {
|
|
ownerId: `OWN-NEW-${newId}`,
|
|
...newOwnerData
|
|
},
|
|
propertyData: {
|
|
propertyId: `P-NEW-${newId}`,
|
|
propertyAddress: newPropertyLocation.address,
|
|
...newPropertyData.details,
|
|
...newPropertyData
|
|
},
|
|
insuranceData: {
|
|
...newInsuranceData
|
|
}
|
|
};
|
|
setProperties(prev => [...prev, newItem]);
|
|
toast.success("New property added to map");
|
|
setSelectedPropertyId(newId); // Select it
|
|
setNewPropertyLocation(null); // Exit create mode
|
|
}
|
|
} catch (err) {
|
|
logger.error("Failed to save property", err);
|
|
toast.error("Failed to save changes", { description: "Please check your input." });
|
|
}
|
|
};
|
|
|
|
if (isLoading) return <Loader fullScreen text="Loading Territory Map..." />;
|
|
|
|
return (
|
|
<div className="w-full h-full relative bg-zinc-50 dark:bg-black overflow-hidden transition-colors duration-300" style={{ height: "calc(100vh - 0px)" }}>
|
|
<MapView
|
|
data={properties}
|
|
onSelect={handlePropertySelect}
|
|
onMapCreate={handleMapCreate}
|
|
/>
|
|
<MapLegend />
|
|
|
|
<PropertyDetailDrawer
|
|
isOpen={!!selectedProperty || !!newPropertyLocation}
|
|
onClose={() => { setSelectedPropertyId(null); setNewPropertyLocation(null); }}
|
|
data={selectedProperty}
|
|
newLocation={newPropertyLocation}
|
|
loadingAddr={loadingAddr}
|
|
onSave={handleSave}
|
|
/>
|
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Maps;
|