feat: Release CRM V2 (Clean History)
Squashed commits for V2 release including Dark Mode, Chatbot, and Landing Page enhancements.
This commit is contained in:
@@ -0,0 +1,615 @@
|
||||
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 } 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 { logger } from '../utils/logger';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// 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-zinc-500",
|
||||
"Hot Lead": "bg-red-500",
|
||||
"Renovated": "bg-blue-500",
|
||||
"Customer": "bg-emerald-500",
|
||||
"Not Interested": "bg-black"
|
||||
};
|
||||
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;
|
||||
};
|
||||
|
||||
// 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%" }}>
|
||||
{/*
|
||||
- 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 = "#71717a"; // zinc-500 default
|
||||
// Adjust colors for light/dark mode visibility if needed, or keep consistent
|
||||
if (item.canvassingStatus === "Hot Lead") color = "#ef4444"; // red-500
|
||||
if (item.canvassingStatus === "Customer") color = "#10b981"; // emerald-500
|
||||
if (item.canvassingStatus === "Renovated") color = "#3b82f6"; // blue-500
|
||||
|
||||
return (
|
||||
<Polygon
|
||||
key={item.id}
|
||||
positions={item.polygon}
|
||||
pathOptions={{
|
||||
color: color,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.2,
|
||||
weight: 2
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: (e) => {
|
||||
L.DomEvent.stopPropagation(e); // Prevent map click
|
||||
onSelect(item);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const Drawer = ({ isOpen, onClose, data, newLocation, onUpdateStatus, onSave, loadingAddr }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Initial State Matching Schema
|
||||
const initialFormState = {
|
||||
// Status
|
||||
status: 'Neutral',
|
||||
|
||||
// 1.1 Basic Property Details
|
||||
propertyType: 'House',
|
||||
builtUpArea: '',
|
||||
lotSize: '',
|
||||
yearBuilt: '',
|
||||
bedrooms: '',
|
||||
bathrooms: '',
|
||||
parkingSpaces: '',
|
||||
|
||||
// 1.2 Transaction & Market
|
||||
purchaseDate: '',
|
||||
purchasePrice: '',
|
||||
marketValue: 0,
|
||||
taxValue: '',
|
||||
|
||||
// 1.3 Renovation
|
||||
roofCondition: 'Good',
|
||||
lastRenovationDate: '',
|
||||
|
||||
// 1.4 Rental Status
|
||||
isRented: 'No', // Yes/No
|
||||
tenantName: '',
|
||||
currentRent: '',
|
||||
leaseEnd: '',
|
||||
ownerWillingToRent: 'Yes',
|
||||
expectedRent: '',
|
||||
|
||||
// 1.5 Location
|
||||
schoolDistrict: '',
|
||||
neighborhoodRating: 5,
|
||||
|
||||
// 2.1 Owner Details
|
||||
ownerName: '',
|
||||
ownerPhone: '',
|
||||
ownerEmail: '',
|
||||
ownerAddress: '',
|
||||
occupation: '',
|
||||
ownershipType: 'Individual',
|
||||
|
||||
// 2.4 Owner Intent
|
||||
willingToSell: 'No',
|
||||
desiredPrice: '',
|
||||
minPrice: '',
|
||||
|
||||
// Notes
|
||||
notes: ''
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState(initialFormState);
|
||||
|
||||
// Reset or Initialize Form
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
// Mapping existing data to form state (handling potentially missing fields safely)
|
||||
setFormData({
|
||||
...initialFormState,
|
||||
status: data.canvassingStatus || 'Neutral',
|
||||
ownerName: data.ownerData?.fullName || '',
|
||||
ownerPhone: data.ownerData?.primaryPhoneNumber || '',
|
||||
ownerEmail: data.ownerData?.emailAddress || '',
|
||||
marketValue: data.propertyData?.currentEstimatedMarketValue || 0,
|
||||
notes: data.propertyData?.notes || '',
|
||||
propertyType: data.propertyData?.propertyType || 'House',
|
||||
// Map other fields if they existed in data, otherwise defaults
|
||||
...data.propertyData?.details, // Assuming we store extra details in a nested object `details` for now or spread root
|
||||
...data.ownerData?.details
|
||||
});
|
||||
setIsEditing(false);
|
||||
} else if (newLocation) {
|
||||
setFormData(initialFormState);
|
||||
setIsEditing(true);
|
||||
}
|
||||
}, [data, newLocation]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(formData);
|
||||
if (data) setIsEditing(false);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isCreating = !data && !!newLocation;
|
||||
const address = data?.propertyData?.propertyAddress || newLocation?.address || "Unknown Location";
|
||||
const coords = data ? null : newLocation;
|
||||
|
||||
// Helper for Inputs
|
||||
const RenderInput = ({ label, field, type = "text", placeholder, options, locked = false }) => {
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<InputGroup label={label}>
|
||||
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-200">
|
||||
{formData[field] || "-"}
|
||||
</span>
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (locked) {
|
||||
return (
|
||||
<InputGroup label={label}>
|
||||
<div className="w-full bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-white/5 rounded-lg py-2 px-3 text-sm text-zinc-400 cursor-not-allowed italic">
|
||||
{placeholder || "Not Applicable"}
|
||||
</div>
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InputGroup label={label}>
|
||||
{options ? (
|
||||
<select
|
||||
value={formData[field]}
|
||||
onChange={(e) => setFormData({ ...formData, [field]: e.target.value })}
|
||||
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg py-2 px-2 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500 appearance-none"
|
||||
>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt} className="bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white">
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
value={formData[field]}
|
||||
onChange={(e) => setFormData({ ...formData, [field]: e.target.value })}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg py-2 px-3 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500 placeholder-zinc-400"
|
||||
/>
|
||||
)}
|
||||
</InputGroup>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`fixed top-4 right-4 bottom-4 w-[500px] z-[1000] flex flex-col transition-transform duration-300 ease-out ${isOpen ? 'translate-x-0' : 'translate-x-[calc(100%+2rem)]'}`}>
|
||||
<SpotlightCard className="h-full flex flex-col overflow-hidden bg-white/95 dark:bg-zinc-900/95 backdrop-blur-3xl border-l border-white/20 shadow-2xl">
|
||||
|
||||
{/* Header */}
|
||||
<div className="relative h-32 bg-gradient-to-br from-zinc-100 to-zinc-200 dark:from-zinc-900 dark:to-black p-6 flex flex-col justify-end shrink-0 border-b border-zinc-200 dark:border-white/5">
|
||||
{/* Pattern */}
|
||||
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-[0.03] dark:opacity-10 pointer-events-none mix-blend-overlay"></div>
|
||||
|
||||
<button onClick={onClose} className="absolute top-4 right-4 p-2 bg-white/50 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 rounded-full transition-colors backdrop-blur-md text-zinc-900 dark:text-white">
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
{isCreating ? <span className="px-2 py-0.5 rounded text-[10px] font-bold uppercase bg-blue-500 text-white">New Entry</span> : <StatusBadge status={isEditing ? formData.status : data.canvassingStatus} />}
|
||||
</div>
|
||||
<h2 className="text-xl font-black text-zinc-900 dark:text-white truncate">{loadingAddr ? "Fetching Address..." : address.split(',')[0]}</h2>
|
||||
<span className="text-xs text-zinc-500 truncate block">{loadingAddr ? "Locating..." : address}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-8 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
|
||||
|
||||
{loadingAddr ? (
|
||||
<div className="flex flex-col items-center justify-center h-40 space-y-3 text-zinc-500">
|
||||
<Loader2 className="animate-spin" size={24} />
|
||||
<span className="text-xs font-bold uppercase">Resolving Location...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Status Selector */}
|
||||
{isEditing && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold text-zinc-500 tracking-widest ml-1">Canvassing Status</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{["Neutral", "Hot Lead", "Customer", "Not Interested"].map(s => (
|
||||
<button key={s} onClick={() => setFormData({ ...formData, status: s })} className={`px-1 py-2 rounded text-[10px] font-bold border transition-all ${formData.status === s ? 'bg-zinc-900 dark:bg-white text-white dark:text-black' : 'bg-transparent border-zinc-200 dark:border-white/10 text-zinc-500'}`}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SECTION 1: BASIC PROPERTY INFO */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-zinc-200 dark:border-white/5 text-blue-600 dark:text-blue-400">
|
||||
<Home size={16} /> <h3 className="text-xs font-black uppercase tracking-widest">Property Basics</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<RenderInput label="Property Type" field="propertyType" options={["House", "Apartment", "Condo", "Commercial", "Land"]} />
|
||||
<RenderInput label="Built-Up Area (sqft)" field="builtUpArea" type="number" />
|
||||
<RenderInput label="Lot Size (sqft)" field="lotSize" type="number" />
|
||||
<RenderInput label="Year Built" field="yearBuilt" type="number" />
|
||||
<div className="grid grid-cols-3 gap-2 col-span-2">
|
||||
<RenderInput label="Beds" field="bedrooms" type="number" />
|
||||
<RenderInput label="Baths" field="bathrooms" type="number" />
|
||||
<RenderInput label="Parking" field="parkingSpaces" type="number" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SECTION 2: VALUES & CONDITION */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-zinc-200 dark:border-white/5 text-emerald-600 dark:text-emerald-400">
|
||||
<DollarSign size={16} /> <h3 className="text-xs font-black uppercase tracking-widest">Value & Condition</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<RenderInput label="Est. Market Value ($)" field="marketValue" type="number" />
|
||||
<RenderInput label="Tax Assessed Value ($)" field="taxValue" type="number" />
|
||||
<RenderInput label="Roof Condition" field="roofCondition" options={["Excellent", "Good", "Fair", "Needs Repair", "Critical"]} />
|
||||
<RenderInput label="Last Reno Date" field="lastRenovationDate" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SECTION 3: RENTAL STATUS (Conditional) */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-zinc-200 dark:border-white/5 text-purple-600 dark:text-purple-400">
|
||||
<div className="p-1 bg-purple-100 dark:bg-purple-900/30 rounded"><User size={12} /></div>
|
||||
<h3 className="text-xs font-black uppercase tracking-widest">Rental Status</h3>
|
||||
</div>
|
||||
|
||||
<RenderInput label="Currently Rented?" field="isRented" options={["Yes", "No"]} />
|
||||
|
||||
{formData.isRented === 'Yes' ? (
|
||||
<div className="p-4 bg-purple-50 dark:bg-purple-900/10 rounded-xl space-y-4 border border-purple-100 dark:border-purple-500/20">
|
||||
<RenderInput label="Tenant Name" field="tenantName" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<RenderInput label="Current Rent" field="currentRent" type="number" />
|
||||
<RenderInput label="Lease End" field="leaseEnd" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-zinc-50 dark:bg-white/5 rounded-xl space-y-4">
|
||||
<RenderInput label="Owner Willing to Rent?" field="ownerWillingToRent" options={["Yes", "No", "Maybe"]} />
|
||||
{formData.ownerWillingToRent !== 'No' && (
|
||||
<RenderInput label="Expected Rent ($)" field="expectedRent" type="number" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SECTION 4: OWNER INTENT (Conditional) */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-zinc-200 dark:border-white/5 text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle size={16} /> <h3 className="text-xs font-black uppercase tracking-widest">Recall & Sales Intent</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<RenderInput label="Willing to Sell?" field="willingToSell" options={["Yes", "No", "Undecided"]} />
|
||||
<RenderInput label="Ownership Type" field="ownershipType" options={["Individual", "Joint", "Corporate", "Trust"]} />
|
||||
</div>
|
||||
|
||||
{formData.willingToSell === 'Yes' ? (
|
||||
<div className="p-4 bg-amber-50 dark:bg-amber-900/10 rounded-xl space-y-4 border border-amber-100 dark:border-amber-500/20">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<RenderInput label="Desired Price" field="desiredPrice" type="number" />
|
||||
<RenderInput label="Min Price" field="minPrice" type="number" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<RenderInput label="Desired Price" field="desiredPrice" locked={true} placeholder="Owner not selling" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SECTION 5: OWNER CONTACT */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-zinc-200 dark:border-white/5 text-zinc-500">
|
||||
<User size={16} /> <h3 className="text-xs font-black uppercase tracking-widest">Owner Contact</h3>
|
||||
</div>
|
||||
<RenderInput label="Full Name" field="ownerName" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<RenderInput label="Phone" field="ownerPhone" />
|
||||
<RenderInput label="Email" field="ownerEmail" />
|
||||
</div>
|
||||
<RenderInput label="Occupation" field="occupation" />
|
||||
</div>
|
||||
|
||||
{/* SECTION 6: NOTES */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-zinc-200 dark:border-white/5 text-zinc-500">
|
||||
<Edit2 size={16} /> <h3 className="text-xs font-black uppercase tracking-widest">Field Notes</h3>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
className="w-full h-32 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-xl p-3 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500 resize-none"
|
||||
value={formData.notes}
|
||||
placeholder="Add notes..."
|
||||
onChange={e => setFormData({ ...formData, notes: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400 bg-zinc-50 dark:bg-white/5 p-4 rounded-xl border border-zinc-200 dark:border-white/5">
|
||||
{formData.notes || "No notes available."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-black/20 backdrop-blur-md shrink-0">
|
||||
{isEditing ? (
|
||||
<button onClick={handleSave} disabled={loadingAddr} className="w-full py-3 bg-zinc-900 dark:bg-white hover:bg-zinc-800 text-white dark:text-black font-bold rounded-xl shadow-lg transition-all flex items-center justify-center space-x-2 disabled:opacity-50">
|
||||
<Save size={18} /> <span>{isCreating ? "Create Entry" : "Save Changes"}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => setIsEditing(true)} className="w-full py-3 bg-white dark:bg-zinc-800 hover:bg-zinc-50 text-zinc-900 dark:text-white border border-zinc-200 dark:border-white/10 font-bold rounded-xl shadow-sm transition-all flex items-center justify-center space-x-2">
|
||||
<Edit2 size={18} /> <span>Edit Details</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InputGroup = ({ label, children }) => (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase font-bold text-zinc-400 tracking-widest ml-0.5">{label}</label>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
// --- MAIN PAGE COMPONENT --- //
|
||||
|
||||
function Maps() {
|
||||
const { properties, setProperties, updatePropertyStatus } = useMockStore();
|
||||
const { user } = useAuth();
|
||||
|
||||
// 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
|
||||
isRented: formData.isRented,
|
||||
tenantName: formData.tenantName,
|
||||
currentRent: formData.currentRent,
|
||||
leaseEnd: formData.leaseEnd,
|
||||
ownerWillingToRent: formData.ownerWillingToRent,
|
||||
expectedRent: formData.expectedRent,
|
||||
|
||||
// Location (can be expanded)
|
||||
schoolDistrict: formData.schoolDistrict,
|
||||
neighborhoodRating: formData.neighborhoodRating
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedPropertyId) {
|
||||
// Update Existing
|
||||
setProperties(prev => prev.map(p => {
|
||||
if (p.id === selectedPropertyId) {
|
||||
return {
|
||||
...p,
|
||||
canvassingStatus: formData.status,
|
||||
ownerData: { ...p.ownerData, ...newOwnerData, details: { ...p.ownerData.details, ...newOwnerData.details } },
|
||||
propertyData: {
|
||||
...p.propertyData,
|
||||
...newPropertyData,
|
||||
details: { ...p.propertyData.details, ...newPropertyData.details } // Merge to keep existing data not in form if any
|
||||
}
|
||||
};
|
||||
}
|
||||
return p;
|
||||
}));
|
||||
} else if (newPropertyLocation) {
|
||||
// Create New
|
||||
const newId = `prop-${Date.now()}`;
|
||||
const newProp = {
|
||||
id: newId,
|
||||
polygon: [], // We don't have a polygon drawing tool yet, implies point-based or empty
|
||||
canvassingStatus: formData.status,
|
||||
ownerData: newOwnerData,
|
||||
propertyData: {
|
||||
...newPropertyData,
|
||||
propertyAddress: newPropertyLocation.address,
|
||||
}
|
||||
};
|
||||
|
||||
// In a real app we'd need to store the LatLng for this new valid prop separately if it has no polygon
|
||||
// For now, we just add it to the list.
|
||||
// NOTE: Since MapsView maps *Polygons*, this new item won't appear on map unless we add a Marker layer or mock a polygon around the point.
|
||||
// For MVP, we will mock a tiny square polygon around the point so it renders.
|
||||
|
||||
const lat = newPropertyLocation.lat;
|
||||
const lng = newPropertyLocation.lng;
|
||||
const size = 0.0001; // Tiny box
|
||||
newProp.polygon = [
|
||||
[lat + size, lng - size],
|
||||
[lat + size, lng + size],
|
||||
[lat - size, lng + size],
|
||||
[lat - size, lng - size]
|
||||
];
|
||||
|
||||
setProperties(prev => [...prev, newProp]);
|
||||
setSelectedPropertyId(newId); // Select the new item
|
||||
setNewPropertyLocation(null); // Clear creation mode
|
||||
}
|
||||
toast.success("Property data saved successfully");
|
||||
} catch (error) {
|
||||
logger.error("Error saving property data", error);
|
||||
toast.error("Failed to save changes", { description: "Please try again." });
|
||||
}
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
isOpen={!!selectedPropertyId || !!newPropertyLocation}
|
||||
onClose={() => { setSelectedPropertyId(null); setNewPropertyLocation(null); }}
|
||||
data={selectedProperty}
|
||||
newLocation={newPropertyLocation}
|
||||
onSave={handleSave}
|
||||
loadingAddr={loadingAddr}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Maps;
|
||||
Reference in New Issue
Block a user