Files
LynkedUpPro_CRM/src/pages/Maps.jsx
T
Satyam bc4e25f132 feat: comprehensive mobile optimization, role-based AI chatbot, and multi-role dashboard enhancements
- Rewrote AI chatbot context system with deep role-aware data injection for all 7 roles (Owner, Admin, Field Agent, Contractor, Subcontractor, Vendor, Customer) plus guest fallback
- Added mobile-responsive bottom-sheet modals across all roles (ChangeOrderDrawer, InvoiceDetailModal, FinancialSummaryModal, VendorFinancialSummaryModal, FinancialDetailsModal)
- Converted Owner Project List and Vendor Orders tables to mobile card views with touch-friendly layouts
- Optimized Vendor Management and People Directory with mobile list/detail toggle and back navigation
- Fixed Document Control mobile view: horizontally scrollable filter tabs, proper overflow chain for document visibility
- Added responsive padding, text scaling, and flex-wrap across StatCard, TaskDetailsModal, OwnerSnapshot, OwnerProjectDetail, VendorDashboard, and DocumentManagement
- Expanded mock data store with richer vendor invoices, orders, documents, and compliance records
- Enhanced Owner Snapshot with financial KPI cards, urgent items panel, and Action Center modal
- Built Contractor Dashboard with task management, financial summary, and performance metrics
- Built Subcontractor Dashboard with clock-in tracking, task assignments, and invoice management
- Enhanced Vendor Dashboard with earnings summary, active orders, compliance status, and performance rating
- Added icon-only tab navigation on mobile for OwnerProjectDetail
- Extended attribution signatures across all platform pages
2026-02-18 12:34:55 +05:30

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='&copy; <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) => {
// Only Field Agents (employees) can create
if (user?.role !== 'FIELD_AGENT' && user?.role !== 'ADMIN') 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;