Implement Lightbox, fix photo gallery accessibility, update local assets and OG tags for Vercel deployment
@@ -12,21 +12,21 @@
|
|||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://lynkeduppro-crm.netlify.app/" />
|
<meta property="og:url" content="https://lynkeduppro-crm.vercel.app/" />
|
||||||
<meta property="og:site_name" content="LynkedUp Pro" />
|
<meta property="og:site_name" content="LynkedUp Pro" />
|
||||||
<meta property="og:title" content="LynkedUp Pro | AI-Powered Roofing Operations" />
|
<meta property="og:title" content="LynkedUp Pro | AI-Powered Roofing Operations" />
|
||||||
<meta property="og:description"
|
<meta property="og:description"
|
||||||
content="The all-in-one geospatial platform for roofing professionals. Features real-time mapping, AI diagnoses, and instant estimates using LiDAR technology." />
|
content="The all-in-one geospatial platform for roofing professionals. Features real-time mapping, AI diagnoses, and instant estimates using LiDAR technology." />
|
||||||
<meta property="og:image" content="https://lynkeduppro-crm.netlify.app/og-logo.png" />
|
<meta property="og:image" content="https://lynkeduppro-crm.vercel.app/og-logo.png" />
|
||||||
<meta property="og:logo" content="https://lynkeduppro-crm.netlify.app/og-icon.png" />
|
<meta property="og:logo" content="https://lynkeduppro-crm.vercel.app/og-icon.png" />
|
||||||
|
|
||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
<meta property="twitter:url" content="https://lynkeduppro-crm.netlify.app/" />
|
<meta property="twitter:url" content="https://lynkeduppro-crm.vercel.app/" />
|
||||||
<meta property="twitter:title" content="LynkedUp Pro | AI-Powered Roofing Operations" />
|
<meta property="twitter:title" content="LynkedUp Pro | AI-Powered Roofing Operations" />
|
||||||
<meta property="twitter:description"
|
<meta property="twitter:description"
|
||||||
content="The all-in-one geospatial platform for roofing professionals. Features real-time mapping, AI diagnoses, and instant estimates using LiDAR technology." />
|
content="The all-in-one geospatial platform for roofing professionals. Features real-time mapping, AI diagnoses, and instant estimates using LiDAR technology." />
|
||||||
<meta property="twitter:image" content="https://lynkeduppro-crm.netlify.app/og-logo.png" />
|
<meta property="twitter:image" content="https://lynkeduppro-crm.vercel.app/og-logo.png" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Icons } from './landing/constants';
|
import { Icons } from './landing/constants';
|
||||||
import MapBg from '../assets/images/hyper_intelligence_bg_image.jpg';
|
import MapBg from '../assets/images/hyper_intelligence_bg_image.jpg';
|
||||||
|
import IntelligenceSidePanel from './dashboard/IntelligenceSidePanel';
|
||||||
|
|
||||||
// Updated with Residential & Commercial mix
|
// Updated with Residential & Commercial mix
|
||||||
const hotspots = [
|
const hotspots = [
|
||||||
@@ -16,7 +17,11 @@ const hotspots = [
|
|||||||
weather: 'Hail Expected',
|
weather: 'Hail Expected',
|
||||||
status: 'Urgent',
|
status: 'Urgent',
|
||||||
specs: { built: '1998', maintenance: '5 years since last coating' },
|
specs: { built: '1998', maintenance: '5 years since last coating' },
|
||||||
management: { name: 'Sterling Properties', contact: 'Sign in for contact details' }
|
management: { name: 'Sterling Properties', contact: 'Sign in for contact details' },
|
||||||
|
photos: [
|
||||||
|
{ id: 1, url: '/assets/images/properties/Warehouse_Roof_Aerial.jpg', caption: 'Warehouse Roof Aerial' },
|
||||||
|
{ id: 2, url: '/assets/images/properties/Office_Roof_Top_View.jpg', caption: 'Office Roof Top' }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -29,7 +34,10 @@ const hotspots = [
|
|||||||
weather: 'Clear',
|
weather: 'Clear',
|
||||||
status: 'Monitoring',
|
status: 'Monitoring',
|
||||||
specs: { built: '1982', maintenance: '20 years since last roof replacement permit' },
|
specs: { built: '1982', maintenance: '20 years since last roof replacement permit' },
|
||||||
management: { name: 'Goldman Group', contact: 'Sign in for contact details' }
|
management: { name: 'Goldman Group', contact: 'Sign in for contact details' },
|
||||||
|
photos: [
|
||||||
|
{ id: 3, url: '/assets/images/properties/Mall_Rooftop_HVAC_Units.jpg', caption: 'Rooftop HVAC Units' }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
@@ -42,7 +50,11 @@ const hotspots = [
|
|||||||
weather: 'Rain Soon',
|
weather: 'Rain Soon',
|
||||||
status: 'Action Required',
|
status: 'Action Required',
|
||||||
specs: { roofSize: '62 sq Roof', eventHistory: '8 years since last known hail event' },
|
specs: { roofSize: '62 sq Roof', eventHistory: '8 years since last known hail event' },
|
||||||
owner: { fico: '715', profile: 'Early Adopter / Long-term value' }
|
owner: { fico: '715', profile: 'Early Adopter / Long-term value' },
|
||||||
|
photos: [
|
||||||
|
{ id: 4, url: '/assets/images/properties/Hail_Damaged_Shingles.jpg', caption: 'Hail Damage Close-up' },
|
||||||
|
{ id: 5, url: '/assets/images/properties/Broken_Roof_Surface.jpg', caption: 'Broken Surface' }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
@@ -55,7 +67,11 @@ const hotspots = [
|
|||||||
weather: 'Windy',
|
weather: 'Windy',
|
||||||
status: 'Low Priority',
|
status: 'Low Priority',
|
||||||
specs: { roofSize: '45 sq Roof', eventHistory: 'High Wind Zone' },
|
specs: { roofSize: '45 sq Roof', eventHistory: 'High Wind Zone' },
|
||||||
owner: { fico: '680', profile: 'First-time Buyer' }
|
owner: { fico: '680', profile: 'First-time Buyer' },
|
||||||
|
photos: [
|
||||||
|
{ id: 6, url: '/assets/images/properties/Cracked_Asphalt_Shingles.jpg', caption: 'Cracked Shingles' },
|
||||||
|
{ id: 7, url: '/assets/images/properties/Curled_Aging_Shingles.jpg', caption: 'Aging Shingles' }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -190,97 +206,10 @@ const IntelligenceMap = () => {
|
|||||||
|
|
||||||
{/* Right Sidebar Panel (Glass Detail View) */}
|
{/* Right Sidebar Panel (Glass Detail View) */}
|
||||||
{activeSpot && (
|
{activeSpot && (
|
||||||
<div className="absolute top-4 right-4 bottom-4 w-80 bg-slate-900/80 backdrop-blur-xl rounded-xl p-6 shadow-2xl border border-white/10 transform transition-transform duration-300 z-40 flex flex-col text-white">
|
<IntelligenceSidePanel
|
||||||
<div className="flex justify-between items-center mb-6 pb-4 border-b border-white/10">
|
data={activeSpot}
|
||||||
<div className="flex items-center gap-2">
|
onClose={() => setActiveSpot(null)}
|
||||||
<div className="w-2 h-2 bg-cyan-400 rounded-full animate-pulse"></div>
|
/>
|
||||||
<h3 className="font-heading font-bold text-lg tracking-wide">INTEL_FEED</h3>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setActiveSpot(null)} className="text-gray-400 hover:text-white transition-colors">✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-grow space-y-6">
|
|
||||||
<div className="p-4 bg-cyan-400/5 rounded-lg border border-cyan-400/20 relative overflow-hidden group/card hover:bg-cyan-400/10 transition-colors">
|
|
||||||
<div className="absolute top-0 right-0 p-1 opacity-20 group-hover/card:opacity-40 transition-opacity">
|
|
||||||
<Icons.Chart />
|
|
||||||
</div>
|
|
||||||
<label className="text-[10px] text-cyan-400 font-bold uppercase tracking-widest mb-1 block">Projected Value</label>
|
|
||||||
<div className="text-3xl font-heading font-bold text-white">{activeSpot.gain}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-1 block">Location Target</label>
|
|
||||||
<div className="text-white font-bold text-lg leading-tight mb-1">{activeSpot.address}</div>
|
|
||||||
<div className="text-xs text-gray-400">Dallas-Fort Worth Metroplex • {activeSpot.type} Zone</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="h-px bg-white/10 w-full"></div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-2 block">Detected Issue</label>
|
|
||||||
<div className={`flex items-center gap-2 font-bold ${activeSpot.problem.includes('Severe') ? 'text-red-500' : 'text-yellow-400'}`}>
|
|
||||||
<span className="animate-pulse">⚠</span> {activeSpot.problem}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dynamic Specs & Intel Based on Type */}
|
|
||||||
{activeSpot.type === 'Commercial' && activeSpot.specs ? (
|
|
||||||
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-1 block">Built</label>
|
|
||||||
<div className="text-sm text-white font-medium">{activeSpot.specs.built}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-1 block">Maintenance</label>
|
|
||||||
<div className="text-sm text-white font-medium">{activeSpot.specs.maintenance}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-slate-800/50 rounded-lg border border-white/5">
|
|
||||||
<label className="text-[10px] text-purple-400 font-bold uppercase tracking-widest mb-1 block">Management</label>
|
|
||||||
<div className="text-sm text-white font-bold">{activeSpot.management.name}</div>
|
|
||||||
<div onClick={() => navigate('/login')} className="text-xs text-blue-400 cursor-pointer hover:underline mt-1">{activeSpot.management.contact}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : activeSpot.type === 'Residential' && activeSpot.specs ? (
|
|
||||||
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-1 block">Roof Specs</label>
|
|
||||||
<div className="text-sm text-white font-medium">{activeSpot.specs.roofSize}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-1 block">Risk History</label>
|
|
||||||
<div className="text-sm text-white font-medium">{activeSpot.specs.eventHistory}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-slate-800/50 rounded-lg border border-white/5">
|
|
||||||
<label className="text-[10px] text-emerald-400 font-bold uppercase tracking-widest mb-1 block">Owner Intelligence</label>
|
|
||||||
<div className="flex justify-between items-end">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-400 mb-0.5">FICO Score</div>
|
|
||||||
<div className="text-sm text-white font-bold">{activeSpot.owner.fico}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-xs text-gray-400 mb-0.5">Profile</div>
|
|
||||||
<div className="text-xs text-white font-medium">{activeSpot.owner.profile}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-gray-400 italic">
|
|
||||||
> ANALYSIS: {activeSpot.type === 'Commercial' ? 'High-value commercial asset at risk. Immediate inspection recommended.' : 'High probability of insurance approval due to recent storm activity.'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onClick={() => navigate('/login')} className="w-full bg-cyan-400 hover:bg-white text-slate-900 font-bold py-3 rounded-lg transition-all duration-300 shadow-[0_0_20px_rgba(0,229,255,0.3)] hover:shadow-[0_0_30px_rgba(255,255,255,0.4)] uppercase tracking-widest text-xs">
|
|
||||||
Dispatch Sales Rep
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Icons } from '../landing/constants';
|
||||||
|
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||||
|
|
||||||
|
const IntelligenceSidePanel = ({ data, onClose }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [activeTab, setActiveTab] = useState('details');
|
||||||
|
// Initialize photos from data or use empty array if not present
|
||||||
|
const [photos, setPhotos] = useState(data.photos || []);
|
||||||
|
// Lightbox State
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState(null); // null = closed, number = open at index
|
||||||
|
|
||||||
|
const panelRef = useRef(null);
|
||||||
|
const closeButtonRef = useRef(null);
|
||||||
|
const lightboxRef = useRef(null);
|
||||||
|
|
||||||
|
// Update photos state if data changes (e.g. switching pins)
|
||||||
|
useEffect(() => {
|
||||||
|
setPhotos(data.photos || []);
|
||||||
|
setActiveTab('details'); // Reset tab when data changes
|
||||||
|
setLightboxIndex(null); // Close lightbox if data changes
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Accessibility: Focus management and Escape key listener
|
||||||
|
useEffect(() => {
|
||||||
|
// Focus the close button when the panel opens
|
||||||
|
if (closeButtonRef.current && !lightboxIndex) {
|
||||||
|
closeButtonRef.current.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (lightboxIndex !== null) {
|
||||||
|
setLightboxIndex(null);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onClose, lightboxIndex]);
|
||||||
|
|
||||||
|
// Lightbox Keyboard Navigation
|
||||||
|
useEffect(() => {
|
||||||
|
if (lightboxIndex === null) return;
|
||||||
|
|
||||||
|
const handleLightboxKeys = (e) => {
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
setLightboxIndex((prev) => (prev + 1) % photos.length);
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
setLightboxIndex((prev) => (prev - 1 + photos.length) % photos.length);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleLightboxKeys);
|
||||||
|
return () => document.removeEventListener('keydown', handleLightboxKeys);
|
||||||
|
}, [lightboxIndex, photos.length]);
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="absolute top-4 right-4 bottom-4 w-80 bg-slate-900/80 backdrop-blur-xl rounded-xl p-6 shadow-2xl border border-white/10 transform transition-transform duration-300 z-40 flex flex-col text-white"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Property Intelligence Details"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center mb-4 pb-4 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 bg-cyan-400 rounded-full animate-pulse"></div>
|
||||||
|
<h3 className="font-heading font-bold text-lg tracking-wide">INTEL_FEED</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
ref={closeButtonRef}
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-cyan-400 rounded-full p-1"
|
||||||
|
aria-label="Close details panel"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-white/10 mb-4" role="tablist">
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-2 text-sm font-bold uppercase tracking-wider transition-colors border-b-2 ${activeTab === 'details' ? 'border-cyan-400 text-cyan-400' : 'border-transparent text-gray-400 hover:text-white'}`}
|
||||||
|
onClick={() => setActiveTab('details')}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'details'}
|
||||||
|
aria-controls="panel-details"
|
||||||
|
id="tab-details"
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-2 text-sm font-bold uppercase tracking-wider transition-colors border-b-2 ${activeTab === 'photos' ? 'border-cyan-400 text-cyan-400' : 'border-transparent text-gray-400 hover:text-white'}`}
|
||||||
|
onClick={() => setActiveTab('photos')}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'photos'}
|
||||||
|
aria-controls="panel-photos"
|
||||||
|
id="tab-photos"
|
||||||
|
>
|
||||||
|
Photos ({photos.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="flex-grow overflow-y-auto custom-scrollbar relative">
|
||||||
|
|
||||||
|
{/* DETAILS TAB */}
|
||||||
|
{activeTab === 'details' && (
|
||||||
|
<div role="tabpanel" id="panel-details" aria-labelledby="tab-details" className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
|
<div className="p-4 bg-cyan-400/5 rounded-lg border border-cyan-400/20 relative overflow-hidden group/card hover:bg-cyan-400/10 transition-colors">
|
||||||
|
<div className="absolute top-0 right-0 p-1 opacity-20 group-hover/card:opacity-40 transition-opacity">
|
||||||
|
<Icons.Chart />
|
||||||
|
</div>
|
||||||
|
<label className="text-[10px] text-cyan-400 font-bold uppercase tracking-widest mb-1 block">Projected Value</label>
|
||||||
|
<div className="text-3xl font-heading font-bold text-white">{data.gain}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-1 block">Location Target</label>
|
||||||
|
<div className="text-white font-bold text-lg leading-tight mb-1">{data.address}</div>
|
||||||
|
<div className="text-xs text-gray-400">Dallas-Fort Worth Metroplex • {data.type} Zone</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="h-px bg-white/10 w-full"></div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-2 block">Detected Issue</label>
|
||||||
|
<div className={`flex items-center gap-2 font-bold ${data.problem.includes('Severe') ? 'text-red-500' : 'text-yellow-400'}`}>
|
||||||
|
<span className="animate-pulse">⚠</span> {data.problem}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dynamic Specs & Intel Based on Type */}
|
||||||
|
{data.type === 'Commercial' && data.specs ? (
|
||||||
|
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-1 block">Built</label>
|
||||||
|
<div className="text-sm text-white font-medium">{data.specs.built}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-1 block">Maintenance</label>
|
||||||
|
<div className="text-sm text-white font-medium">{data.specs.maintenance}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-slate-800/50 rounded-lg border border-white/5">
|
||||||
|
<label className="text-[10px] text-purple-400 font-bold uppercase tracking-widest mb-1 block">Management</label>
|
||||||
|
<div className="text-sm text-white font-bold">{data.management.name}</div>
|
||||||
|
<div
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
className="text-xs text-blue-400 cursor-pointer hover:underline mt-1 focus:outline-none focus:ring-1 focus:ring-blue-400 rounded"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') navigate('/login'); }}
|
||||||
|
>
|
||||||
|
{data.management.contact}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : data.type === 'Residential' && data.specs ? (
|
||||||
|
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-1 block">Roof Specs</label>
|
||||||
|
<div className="text-sm text-white font-medium">{data.specs.roofSize}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-gray-500 font-bold uppercase tracking-widest mb-1 block">Risk History</label>
|
||||||
|
<div className="text-sm text-white font-medium">{data.specs.eventHistory}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-slate-800/50 rounded-lg border border-white/5">
|
||||||
|
<label className="text-[10px] text-emerald-400 font-bold uppercase tracking-widest mb-1 block">Owner Intelligence</label>
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-400 mb-0.5">FICO Score</div>
|
||||||
|
<div className="text-sm text-white font-bold">{data.owner.fico}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-gray-400 mb-0.5">Profile</div>
|
||||||
|
<div className="text-xs text-white font-medium">{data.owner.profile}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-400 italic">
|
||||||
|
> ANALYSIS: {data.type === 'Commercial' ? 'High-value commercial asset at risk. Immediate inspection recommended.' : 'High probability of insurance approval due to recent storm activity.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
className="w-full bg-cyan-400 hover:bg-white text-slate-900 font-bold py-3 rounded-lg transition-all duration-300 shadow-[0_0_20px_rgba(0,229,255,0.3)] hover:shadow-[0_0_30px_rgba(255,255,255,0.4)] uppercase tracking-widest text-xs focus:ring-2 focus:ring-offset-2 focus:ring-cyan-400 focus:outline-none"
|
||||||
|
aria-label="Dispatch Sales Rep"
|
||||||
|
>
|
||||||
|
Dispatch Sales Rep
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PHOTOS TAB */}
|
||||||
|
{activeTab === 'photos' && (
|
||||||
|
<div role="tabpanel" id="panel-photos" aria-labelledby="tab-photos" className="space-y-4 animate-in fade-in slide-in-from-right-4 duration-300 h-full flex flex-col">
|
||||||
|
|
||||||
|
{/* Photo Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 pb-20"> {/* pb-20 for bottom fixed button space if needed, though button is inline here */}
|
||||||
|
{photos.map((photo, index) => (
|
||||||
|
<div
|
||||||
|
key={photo.id}
|
||||||
|
className="relative aspect-square rounded-lg overflow-hidden group border border-white/10 hover:border-cyan-400/50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => setLightboxIndex(index)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={photo.url}
|
||||||
|
alt={photo.caption || 'Property view'}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<span className="text-cyan-400 text-xs font-bold uppercase tracking-widest border border-cyan-400/50 px-2 py-1 rounded-full backdrop-blur-sm shadow-[0_0_10px_rgba(34,211,238,0.3)]">View</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Upload Placeholders if empty */}
|
||||||
|
{photos.length === 0 && (
|
||||||
|
<div className="col-span-2 py-8 text-center border-2 border-dashed border-white/10 rounded-lg bg-white/5">
|
||||||
|
<p className="text-gray-400 text-xs mb-2">No photos available</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LIGHTBOX OVERLAY */}
|
||||||
|
{lightboxIndex !== null && photos[lightboxIndex] && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[2000] bg-black/95 backdrop-blur-xl flex items-center justify-center p-4 animate-in fade-in duration-200 focus:outline-none"
|
||||||
|
tabIndex={-1}
|
||||||
|
ref={lightboxRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Photo Lightbox"
|
||||||
|
>
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setLightboxIndex(null)}
|
||||||
|
className="absolute top-4 right-4 text-cyan-400 hover:text-white p-2 rounded-full hover:bg-white/10 transition-colors z-50 focus:ring-2 focus:ring-cyan-400"
|
||||||
|
aria-label="Close photo view"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Navigation Left */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setLightboxIndex((prev) => (prev - 1 + photos.length) % photos.length); }}
|
||||||
|
className="absolute left-4 text-cyan-400/70 hover:text-cyan-400 p-3 rounded-full hover:bg-white/10 transition-colors z-50 focus:ring-2 focus:ring-cyan-400 hidden md:block"
|
||||||
|
aria-label="Previous photo"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={32} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Main Image */}
|
||||||
|
<div className="relative max-w-5xl max-h-[85vh] w-full h-full flex flex-col items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={photos[lightboxIndex].url}
|
||||||
|
alt={photos[lightboxIndex].caption || "Full screen view"}
|
||||||
|
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl border border-white/10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute bottom-[-40px] text-cyan-400/50 text-xs uppercase tracking-widest font-mono">
|
||||||
|
IMG_SEQ: {lightboxIndex + 1} / {photos.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Right */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setLightboxIndex((prev) => (prev + 1) % photos.length); }}
|
||||||
|
className="absolute right-4 text-cyan-400/70 hover:text-cyan-400 p-3 rounded-full hover:bg-white/10 transition-colors z-50 focus:ring-2 focus:ring-cyan-400 hidden md:block"
|
||||||
|
aria-label="Next photo"
|
||||||
|
>
|
||||||
|
<ChevronRight size={32} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Mobile Navigation Hints */}
|
||||||
|
<div className="md:hidden absolute bottom-8 flex space-x-8 text-cyan-400/30 text-xs pointer-events-none font-mono uppercase tracking-widest">
|
||||||
|
<span>< PREV</span>
|
||||||
|
<span>NEXT ></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IntelligenceSidePanel;
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { X, Home, DollarSign, User, Edit2, Save, Info, AlertCircle, ShieldCheck, Loader2, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
|
||||||
|
import { SpotlightCard } from '../SpotlightCard';
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, loadingAddr, allowUpload = true }) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState('details');
|
||||||
|
// Initialize photos from data or use empty array
|
||||||
|
const [photos, setPhotos] = useState([]);
|
||||||
|
|
||||||
|
// Lightbox State
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState(null); // null = closed, number = open at index
|
||||||
|
|
||||||
|
const panelRef = useRef(null);
|
||||||
|
const closeButtonRef = useRef(null);
|
||||||
|
const lightboxRef = useRef(null);
|
||||||
|
|
||||||
|
// 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: '',
|
||||||
|
// 3.0 Insurance Information
|
||||||
|
insurance_company: '',
|
||||||
|
insurance_company_not_listed: false,
|
||||||
|
damage_location: '',
|
||||||
|
date_of_loss: '',
|
||||||
|
claim_filed: 'No',
|
||||||
|
claim_number: '',
|
||||||
|
has_paperwork: 'No',
|
||||||
|
adjuster_name: '',
|
||||||
|
adjuster_phone: '',
|
||||||
|
adjuster_ext: '',
|
||||||
|
adjuster_type: '',
|
||||||
|
adjuster_fax: '',
|
||||||
|
adjuster_email: '',
|
||||||
|
met_with_adjuster: 'No',
|
||||||
|
claim_approved: 'No',
|
||||||
|
// Notes
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState(initialFormState);
|
||||||
|
|
||||||
|
// Reset or Initialize Form
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
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',
|
||||||
|
builtUpArea: data.propertyData?.totalBuiltUpArea || '',
|
||||||
|
lotSize: data.propertyData?.lotPlotSize || '',
|
||||||
|
yearBuilt: data.propertyData?.yearBuilt || '',
|
||||||
|
bedrooms: data.propertyData?.numberOfBedrooms || '',
|
||||||
|
bathrooms: data.propertyData?.numberOfBathrooms || '',
|
||||||
|
parkingSpaces: data.propertyData?.numberOfParkingSpaces || '',
|
||||||
|
taxValue: data.propertyData?.propertyTaxAssessmentValue || '',
|
||||||
|
roofCondition: data.propertyData?.roofCondition || 'Good',
|
||||||
|
lastRenovationDate: data.propertyData?.lastMajorRenovationDate || '',
|
||||||
|
isRented: data.propertyData?.currentlyRented ? 'Yes' : 'No',
|
||||||
|
tenantName: data.propertyData?.currentTenantName || '',
|
||||||
|
currentRent: data.propertyData?.currentMonthlyRentAmount || '',
|
||||||
|
leaseEnd: data.propertyData?.leaseEndDate || '',
|
||||||
|
ownerWillingToRent: data.propertyData?.ownerWillingToRent ? 'Yes' : 'No',
|
||||||
|
expectedRent: data.propertyData?.expectedMonthlyRentAmount || '',
|
||||||
|
schoolDistrict: data.propertyData?.schoolDistrict || '',
|
||||||
|
neighborhoodRating: data.propertyData?.neighborhoodRating || 5,
|
||||||
|
occupation: data.ownerData?.occupation || '',
|
||||||
|
ownershipType: data.ownerData?.ownershipType || 'Individual',
|
||||||
|
willingToSell: data.ownerData?.willingToSellProperty ? 'Yes' : 'No',
|
||||||
|
desiredPrice: data.ownerData?.desiredSellingPrice || '',
|
||||||
|
minPrice: data.ownerData?.minimumAcceptableSellingPrice || '',
|
||||||
|
ownerAddress: data.ownerData?.mailingAddress || '',
|
||||||
|
insurance_company: data.insuranceData?.insurance_company || '',
|
||||||
|
insurance_company_not_listed: data.insuranceData?.insurance_company_not_listed || false,
|
||||||
|
damage_location: data.insuranceData?.damage_location || '',
|
||||||
|
date_of_loss: data.insuranceData?.date_of_loss || '',
|
||||||
|
claim_filed: data.insuranceData?.claim_filed ? 'Yes' : 'No',
|
||||||
|
claim_number: data.insuranceData?.claim_number || '',
|
||||||
|
has_paperwork: data.insuranceData?.has_paperwork ? 'Yes' : 'No',
|
||||||
|
adjuster_name: data.insuranceData?.adjuster_name || '',
|
||||||
|
adjuster_phone: data.insuranceData?.adjuster_phone || '',
|
||||||
|
adjuster_ext: data.insuranceData?.adjuster_ext || '',
|
||||||
|
adjuster_type: data.insuranceData?.adjuster_type || '',
|
||||||
|
adjuster_fax: data.insuranceData?.adjuster_fax || '',
|
||||||
|
adjuster_email: data.insuranceData?.adjuster_email || '',
|
||||||
|
met_with_adjuster: data.insuranceData?.met_with_adjuster ? 'Yes' : 'No',
|
||||||
|
claim_approved: data.insuranceData?.claim_approved ? 'Yes' : 'No',
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
setPhotos(data.photos || []);
|
||||||
|
} else if (newLocation) {
|
||||||
|
setFormData(initialFormState);
|
||||||
|
setIsEditing(true);
|
||||||
|
setPhotos([]);
|
||||||
|
}
|
||||||
|
setActiveTab('details');
|
||||||
|
}, [data, newLocation]);
|
||||||
|
|
||||||
|
// Accessibility: Focus and key listeners for Drawer
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && closeButtonRef.current) {
|
||||||
|
closeButtonRef.current.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
// If Lightbox is open, close it first
|
||||||
|
if (lightboxIndex !== null) {
|
||||||
|
setLightboxIndex(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose, lightboxIndex]);
|
||||||
|
|
||||||
|
// Lightbox Keyboard Navigation
|
||||||
|
useEffect(() => {
|
||||||
|
if (lightboxIndex === null) return;
|
||||||
|
|
||||||
|
const handleLightboxKeys = (e) => {
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
setLightboxIndex((prev) => (prev + 1) % photos.length);
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
setLightboxIndex((prev) => (prev - 1 + photos.length) % photos.length);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleLightboxKeys);
|
||||||
|
return () => document.removeEventListener('keydown', handleLightboxKeys);
|
||||||
|
}, [lightboxIndex, photos.length]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(formData);
|
||||||
|
if (data) setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Photo Logic
|
||||||
|
const handleUploadPhoto = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const newPhoto = {
|
||||||
|
id: Date.now(),
|
||||||
|
url: URL.createObjectURL(file), // Mock URL
|
||||||
|
caption: file.name
|
||||||
|
};
|
||||||
|
setPhotos([...photos, newPhoto]);
|
||||||
|
// Ideally notify parent to save photos
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePhoto = (id) => {
|
||||||
|
setPhotos(photos.filter(p => p.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const isCreating = !data && !!newLocation;
|
||||||
|
const address = data?.propertyData?.propertyAddress || newLocation?.address || "Unknown Location";
|
||||||
|
|
||||||
|
// 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 z-[1000] flex flex-col transition-transform duration-300 ease-out inset-0 md:inset-auto md:top-4 md:right-4 md:bottom-4 md:w-[500px] ${isOpen ? 'translate-x-0' : 'translate-x-full md:translate-x-[calc(100%+2rem)]'}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Property Details"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<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
|
||||||
|
ref={closeButtonRef}
|
||||||
|
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 focus:outline-none focus:ring-2 focus:ring-zinc-500"
|
||||||
|
aria-label="Close property details"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
{!loadingAddr && (
|
||||||
|
<div className="flex border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-black/20" role="tablist">
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-3 text-xs font-bold uppercase tracking-wider transition-colors border-b-2 ${activeTab === 'details' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||||
|
onClick={() => setActiveTab('details')}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'details'}
|
||||||
|
aria-controls="prop-details"
|
||||||
|
>
|
||||||
|
Property Details
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-3 text-xs font-bold uppercase tracking-wider transition-colors border-b-2 ${activeTab === 'photos' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||||
|
onClick={() => setActiveTab('photos')}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'photos'}
|
||||||
|
aria-controls="prop-photos"
|
||||||
|
>
|
||||||
|
Photos ({photos.length})
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* DETAILS TAB */}
|
||||||
|
{activeTab === 'details' && (
|
||||||
|
<div role="tabpanel" id="prop-details" className="space-y-8 animate-in fade-in slide-in-from-right-4 duration-200">
|
||||||
|
{/* 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-2 md:grid-cols-3 gap-2">
|
||||||
|
{["Neutral", "Hot Lead", "Renovated", "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-1 md: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-1 md:grid-cols-3 gap-2 col-span-1 md: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-1 md: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">
|
||||||
|
<h4 className="text-[10px] uppercase font-bold text-purple-600 dark:text-purple-400 border-b border-purple-200 dark:border-purple-500/20 pb-1 mb-2">Tenant Profile</h4>
|
||||||
|
<RenderInput label="Full Name *" field="tenantName" placeholder="Required" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<RenderInput label="Phone *" field="tenantPhone" placeholder="Required" />
|
||||||
|
<RenderInput label="Email" field="tenantEmail" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<RenderInput label="Occupation" field="tenantOccupation" />
|
||||||
|
<RenderInput label="Employer" field="tenantEmployer" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<RenderInput label="Annual Income" field="tenantIncome" />
|
||||||
|
<RenderInput label="Family Status" field="livingStatus" options={["Single", "Family", "Couple", "Roommates"]} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="text-[10px] uppercase font-bold text-purple-600 dark:text-purple-400 border-b border-purple-200 dark:border-purple-500/20 pb-1 mb-2 mt-4">Lease Details</h4>
|
||||||
|
<RenderInput label="Lease Type" field="leaseType" options={["Residential Standard", "Commercial NNN", "Short Term", "Month-to-Month"]} />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||||
|
<RenderInput label="Signed" field="leaseSigned" type="date" />
|
||||||
|
<RenderInput label="Start" field="leaseStart" type="date" />
|
||||||
|
<RenderInput label="End" field="leaseEnd" type="date" />
|
||||||
|
</div>
|
||||||
|
<RenderInput label="Monthly Rent ($)" field="currentRent" type="number" />
|
||||||
|
</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: INSURANCE INFORMATION */}
|
||||||
|
<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">
|
||||||
|
<ShieldCheck size={16} /> <h3 className="text-xs font-black uppercase tracking-widest">Insurance Information</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<RenderInput label="Insurance Company" field="insurance_company" options={["State Farm", "Allstate", "Liberty Mutual", "Farmers", "Nationwide", "USAA", "Chubb", "Travelers", "Progressive", "American Family", "Other"]} />
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.insurance_company_not_listed}
|
||||||
|
onChange={(e) => isEditing && setFormData({ ...formData, insurance_company_not_listed: e.target.checked })}
|
||||||
|
disabled={!isEditing}
|
||||||
|
className="rounded border-zinc-300 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-zinc-500">Company Not Listed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<RenderInput label="Claim Filed?" field="claim_filed" options={["Yes", "No"]} />
|
||||||
|
<RenderInput label="Date of Loss" field="date_of_loss" type="date" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.claim_filed === 'Yes' && (
|
||||||
|
<div className="p-4 bg-blue-50 dark:bg-blue-900/10 rounded-xl space-y-4 border border-blue-100 dark:border-blue-500/20">
|
||||||
|
<RenderInput label="Damage Location" field="damage_location" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<RenderInput label="Claim Number" field="claim_number" />
|
||||||
|
<RenderInput label="Has Paperwork?" field="has_paperwork" options={["Yes", "No"]} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="text-[10px] uppercase font-bold text-blue-600 dark:text-blue-400 border-b border-blue-200 dark:border-blue-500/20 pb-1 mb-2 mt-2">Adjuster Details</h4>
|
||||||
|
<RenderInput label="Adjuster Name" field="adjuster_name" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||||
|
<RenderInput label="Phone" field="adjuster_phone" />
|
||||||
|
<RenderInput label="Ext" field="adjuster_ext" />
|
||||||
|
<RenderInput label="Fax" field="adjuster_fax" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<RenderInput label="Email" field="adjuster_email" />
|
||||||
|
<RenderInput label="Type" field="adjuster_type" options={["Staff Adjuster", "Independent Adjuster", "Public Adjuster"]} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<RenderInput label="Met Adjuster?" field="met_with_adjuster" options={["Yes", "No"]} />
|
||||||
|
<RenderInput label="Claim Approved?" field="claim_approved" options={["Yes", "No"]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SECTION 5: 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-1 md: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-1 md: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-1 md: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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PHOTOS TAB */}
|
||||||
|
{activeTab === 'photos' && (
|
||||||
|
<div role="tabpanel" id="prop-photos" className="space-y-4 animate-in fade-in slide-in-from-right-4 duration-200 h-full flex flex-col">
|
||||||
|
<div className="grid grid-cols-2 gap-3 pb-4">
|
||||||
|
{photos.map((photo, index) => (
|
||||||
|
<div key={photo.id} className="relative aspect-square rounded-lg overflow-hidden group border border-white/10 hover:border-blue-500/50 transition-colors cursor-pointer" onClick={() => setLightboxIndex(index)}>
|
||||||
|
<img
|
||||||
|
src={photo.url}
|
||||||
|
alt={photo.caption || 'Property view'}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<span className="text-white text-xs font-bold uppercase tracking-widest border border-white/50 px-2 py-1 rounded-full backdrop-blur-sm">View</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allowUpload && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDeletePhoto(photo.id); }}
|
||||||
|
className="absolute top-2 right-2 bg-red-500/80 hover:bg-red-600 text-white p-1.5 rounded-full backdrop-blur-sm transition-transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-red-500 opacity-0 group-hover:opacity-100"
|
||||||
|
aria-label={`Delete photo ${photo.caption}`}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{photos.length === 0 && (
|
||||||
|
<div className="col-span-2 py-12 text-center border-2 border-dashed border-zinc-200 dark:border-white/10 rounded-lg bg-zinc-50 dark:bg-white/5">
|
||||||
|
<div className="w-12 h-12 bg-zinc-100 dark:bg-white/10 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||||
|
<Home size={24} className="text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-zinc-400 text-xs mb-1">No photos added yet</p>
|
||||||
|
<p className="text-zinc-500 text-[10px]">{allowUpload ? "Upload photos to document property condition" : "No imagery available for this property"}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allowUpload && (
|
||||||
|
<div className="mt-auto pt-4 sticky bottom-0 bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-t border-zinc-200 dark:border-white/10 pb-1">
|
||||||
|
<label className="flex items-center justify-center w-full gap-2 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-900 dark:text-white font-bold py-3 rounded-lg transition-colors cursor-pointer border border-zinc-200 dark:border-white/10 hover:border-blue-500/50 focus-within:ring-2 focus-within:ring-blue-500">
|
||||||
|
<span className="text-xl">+</span>
|
||||||
|
<span className="text-xs uppercase tracking-widest">Upload Photo</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleUploadPhoto}
|
||||||
|
aria-label="Upload a photo"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer (Property Details Editing) */}
|
||||||
|
{activeTab === 'details' && !loadingAddr && (
|
||||||
|
<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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
<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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
<Edit2 size={18} /> <span>Edit Details</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SpotlightCard>
|
||||||
|
|
||||||
|
{/* LIGHTBOX OVERLAY */}
|
||||||
|
{lightboxIndex !== null && photos[lightboxIndex] && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[2000] bg-black/95 backdrop-blur-sm flex items-center justify-center p-4 animate-in fade-in duration-200 focus:outline-none"
|
||||||
|
tabIndex={-1}
|
||||||
|
ref={lightboxRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Photo Lightbox"
|
||||||
|
>
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setLightboxIndex(null)}
|
||||||
|
className="absolute top-4 right-4 text-white/70 hover:text-white p-2 rounded-full hover:bg-white/10 transition-colors z-50 focus:ring-2 focus:ring-white"
|
||||||
|
aria-label="Close photo view"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Navigation Left */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setLightboxIndex((prev) => (prev - 1 + photos.length) % photos.length); }}
|
||||||
|
className="absolute left-4 text-white/70 hover:text-white p-3 rounded-full hover:bg-white/10 transition-colors z-50 focus:ring-2 focus:ring-white hidden md:block"
|
||||||
|
aria-label="Previous photo"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={32} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Main Image */}
|
||||||
|
<div className="relative max-w-5xl max-h-[85vh] w-full h-full flex flex-col items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={photos[lightboxIndex].url}
|
||||||
|
alt={photos[lightboxIndex].caption || "Full screen view"}
|
||||||
|
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute bottom-[-40px] text-white/50 text-xs uppercase tracking-widest font-mono">
|
||||||
|
{lightboxIndex + 1} / {photos.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Right */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setLightboxIndex((prev) => (prev + 1) % photos.length); }}
|
||||||
|
className="absolute right-4 text-white/70 hover:text-white p-3 rounded-full hover:bg-white/10 transition-colors z-50 focus:ring-2 focus:ring-white hidden md:block"
|
||||||
|
aria-label="Next photo"
|
||||||
|
>
|
||||||
|
<ChevronRight size={32} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Mobile Navigation Hints (Optional) */}
|
||||||
|
<div className="md:hidden absolute bottom-8 flex space-x-8 text-white/30 text-xs pointer-events-none">
|
||||||
|
<span>← Swipe Left</span>
|
||||||
|
<span>Swipe Right →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertyDetailDrawer;
|
||||||
@@ -62,7 +62,11 @@ const PROPERTY_STYLES = [
|
|||||||
{
|
{
|
||||||
label: "Luxury Estate",
|
label: "Luxury Estate",
|
||||||
canvassingStatus: "Customer",
|
canvassingStatus: "Customer",
|
||||||
photos: ["https://images.unsplash.com/photo-1600596542815-2a4d9f6facb8?w=800", "https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=800", "https://images.unsplash.com/photo-1512917774080-9991f1c4c750?w=800"],
|
photos: [
|
||||||
|
{ id: 'le_1', url: "/assets/images/properties/Luxury_Driveway_Residence.jpg", caption: "Front Exterior" },
|
||||||
|
{ id: 'le_2', url: "/assets/images/properties/Sunny_Suburban_House.jpg", caption: "Side Elevation" },
|
||||||
|
{ id: 'le_3', url: "/assets/images/properties/Residential_Roof_Texture.jpg", caption: "Roof Quality" }
|
||||||
|
],
|
||||||
baseValue: 850000,
|
baseValue: 850000,
|
||||||
areaBase: 4200,
|
areaBase: 4200,
|
||||||
beds: 5,
|
beds: 5,
|
||||||
@@ -74,7 +78,12 @@ const PROPERTY_STYLES = [
|
|||||||
{
|
{
|
||||||
label: "Standard Family Home",
|
label: "Standard Family Home",
|
||||||
canvassingStatus: "Neutral",
|
canvassingStatus: "Neutral",
|
||||||
photos: ["https://images.unsplash.com/photo-1512917774080-9991f1c4c750?w=800", "https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=800", "https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=800"],
|
photos: [
|
||||||
|
{ id: 'sf_1', url: "/assets/images/properties/Modern_White_Suburban_Home.jpg", caption: "Front View" },
|
||||||
|
{ id: 'sf_2', url: "/assets/images/properties/Brick_Front_Porch_Home.jpg", caption: "Entryway" },
|
||||||
|
{ id: 'sf_3', url: "/assets/images/properties/Overlapping_Roof_Shingles.jpg", caption: "Roof Condition" },
|
||||||
|
{ id: 'sf_4', url: "/assets/images/properties/Red_Shingle_Cottage.jpg", caption: "Cottage Style" }
|
||||||
|
],
|
||||||
baseValue: 450000,
|
baseValue: 450000,
|
||||||
areaBase: 2800,
|
areaBase: 2800,
|
||||||
beds: 4,
|
beds: 4,
|
||||||
@@ -86,7 +95,13 @@ const PROPERTY_STYLES = [
|
|||||||
{
|
{
|
||||||
label: "Fixer Upper",
|
label: "Fixer Upper",
|
||||||
canvassingStatus: "Hot Lead",
|
canvassingStatus: "Hot Lead",
|
||||||
photos: ["https://images.unsplash.com/photo-1605276374104-dee2a0ed3cd6?w=800", "https://images.unsplash.com/photo-1600566753376-12c8ab7fb75b?w=800", "https://images.unsplash.com/photo-1600585152220-90363fe7e115?w=800"],
|
photos: [
|
||||||
|
{ id: 'fu_1', url: "/assets/images/properties/Beige_Two_Story_House.jpg", caption: "Street View" },
|
||||||
|
{ id: 'fu_2', url: "/assets/images/properties/Hail_Damaged_Shingles.jpg", caption: "Hail Damage" },
|
||||||
|
{ id: 'fu_3', url: "/assets/images/properties/Broken_Roof_Surface.jpg", caption: "Critical Repairs Needed" },
|
||||||
|
{ id: 'fu_4', url: "/assets/images/properties/Cracked_Storm_Shingles.jpg", caption: "Storm Damage" },
|
||||||
|
{ id: 'fu_5', url: "/assets/images/properties/Storm_Worn_Roof.jpg", caption: "Worn Roof" }
|
||||||
|
],
|
||||||
baseValue: 320000,
|
baseValue: 320000,
|
||||||
areaBase: 2400,
|
areaBase: 2400,
|
||||||
beds: 3,
|
beds: 3,
|
||||||
@@ -98,7 +113,12 @@ const PROPERTY_STYLES = [
|
|||||||
{
|
{
|
||||||
label: "Renovated Modern",
|
label: "Renovated Modern",
|
||||||
canvassingStatus: "Renovated",
|
canvassingStatus: "Renovated",
|
||||||
photos: ["https://images.unsplash.com/photo-1600585154526-990dced4db0d?w=800", "https://images.unsplash.com/photo-1600573472592-401b489a3cdc?w=800", "https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=800"],
|
photos: [
|
||||||
|
{ id: 'rm_1', url: "/assets/images/properties/Minimal_Flat_Roof_House.jpg", caption: "Modern Facade" },
|
||||||
|
{ id: 'rm_2', url: "/assets/images/properties/Red_Tiled_Roof_House.jpg", caption: "New Roof" },
|
||||||
|
{ id: 'rm_3', url: "/assets/images/properties/Roof_Inspection_Angle.jpg", caption: "Drone Inspection" },
|
||||||
|
{ id: 'rm_4', url: "/assets/images/properties/Red_Metal_Roof_Home.jpg", caption: "Metal Roof Upgrade" }
|
||||||
|
],
|
||||||
baseValue: 580000,
|
baseValue: 580000,
|
||||||
areaBase: 3200,
|
areaBase: 3200,
|
||||||
beds: 4,
|
beds: 4,
|
||||||
@@ -295,7 +315,10 @@ function generateProperties() {
|
|||||||
polygon: generatePolygon(loc.lat, loc.lng),
|
polygon: generatePolygon(loc.lat, loc.lng),
|
||||||
// Placeholder status, will be overwritten
|
// Placeholder status, will be overwritten
|
||||||
canvassingStatus: "Neutral",
|
canvassingStatus: "Neutral",
|
||||||
photos: isCommercial ? ["https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800", "https://images.unsplash.com/photo-1542665952-14513db15293?w=800"] : style.photos,
|
photos: isCommercial ? [
|
||||||
|
{ id: `com_${index}_1`, url: "/assets/images/properties/Apartment_Complex.jpg", caption: "Exterior View" },
|
||||||
|
{ id: `com_${index}_2`, url: "/assets/images/properties/Mall_Rooftop_HVAC_Units.jpg", caption: "HVAC Systems" }
|
||||||
|
] : style.photos,
|
||||||
|
|
||||||
// --- NEW FIELDS FOR ADMIN ACTION CENTER ---
|
// --- NEW FIELDS FOR ADMIN ACTION CENTER ---
|
||||||
assignedAgentId: null, // Default unassigned
|
assignedAgentId: null, // Default unassigned
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import { useMockStore } from '../data/mockStore';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { SpotlightCard } from '../components/SpotlightCard';
|
import { SpotlightCard } from '../components/SpotlightCard';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
import PropertyDetailDrawer from '../components/maps/PropertyDetailDrawer';
|
||||||
|
|
||||||
|
// --- ICONS ---
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { toast } from 'sonner';
|
import { toast }
|
||||||
|
from 'sonner';
|
||||||
import Loader from '../components/Loader';
|
import Loader from '../components/Loader';
|
||||||
|
|
||||||
// Fix for default Leaflet marker icons not showing up
|
// Fix for default Leaflet marker icons not showing up
|
||||||
@@ -171,484 +175,6 @@ const MapView = ({ data, onSelect, onMapCreate }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RenderInput = ({ label, field, type = "text", placeholder, options, min, max }) => {
|
|
||||||
// ... helper logic ...
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col space-y-1">
|
|
||||||
{/* ... */}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... (skipping to Drawer content) ...
|
|
||||||
// Assuming the Drawer section is below lines 300
|
|
||||||
// I will target the specific Status Select input lower down in a separate replacement if I can't catch it here.
|
|
||||||
// Wait, the instruction asked to do it all. The previous `view_file` didn't show the `Drawer` render part fully recently.
|
|
||||||
// I'll stick to updating Legend and MapView first here as they are contiguous. I will do the Dropdown and Badge in a second pass if not safely accessible in this chunk range.
|
|
||||||
// Actually, I can probably safely target just the Legend and MapView here as per the `StartLine`/`EndLine`.
|
|
||||||
// The Drawer render for `status` options is likely further down.
|
|
||||||
|
|
||||||
// Let's split this. First Edit: Legend and MapView Colors.
|
|
||||||
|
|
||||||
|
|
||||||
const Drawer = ({ isOpen, onClose, data, newLocation, onUpdateStatus, onSave, loadingAddr }) => {
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
|
|
||||||
// Initial State Matching Schema
|
|
||||||
// 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: '',
|
|
||||||
|
|
||||||
// 3.0 Insurance Information
|
|
||||||
insurance_company: '',
|
|
||||||
insurance_company_not_listed: false,
|
|
||||||
damage_location: '',
|
|
||||||
date_of_loss: '',
|
|
||||||
claim_filed: 'No',
|
|
||||||
claim_number: '',
|
|
||||||
has_paperwork: 'No',
|
|
||||||
adjuster_name: '',
|
|
||||||
adjuster_phone: '',
|
|
||||||
adjuster_ext: '',
|
|
||||||
adjuster_type: '',
|
|
||||||
adjuster_fax: '',
|
|
||||||
adjuster_email: '',
|
|
||||||
met_with_adjuster: 'No',
|
|
||||||
claim_approved: 'No',
|
|
||||||
|
|
||||||
// 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',
|
|
||||||
// Correctly map fields from mockStore structure (flat objects) to form state
|
|
||||||
builtUpArea: data.propertyData?.totalBuiltUpArea || '',
|
|
||||||
lotSize: data.propertyData?.lotPlotSize || '',
|
|
||||||
yearBuilt: data.propertyData?.yearBuilt || '',
|
|
||||||
bedrooms: data.propertyData?.numberOfBedrooms || '',
|
|
||||||
bathrooms: data.propertyData?.numberOfBathrooms || '',
|
|
||||||
parkingSpaces: data.propertyData?.numberOfParkingSpaces || '',
|
|
||||||
|
|
||||||
taxValue: data.propertyData?.propertyTaxAssessmentValue || '',
|
|
||||||
roofCondition: data.propertyData?.roofCondition || 'Good',
|
|
||||||
lastRenovationDate: data.propertyData?.lastMajorRenovationDate || '',
|
|
||||||
|
|
||||||
isRented: data.propertyData?.currentlyRented ? 'Yes' : 'No',
|
|
||||||
tenantName: data.propertyData?.currentTenantName || '',
|
|
||||||
currentRent: data.propertyData?.currentMonthlyRentAmount || '',
|
|
||||||
leaseEnd: data.propertyData?.leaseEndDate || '',
|
|
||||||
ownerWillingToRent: data.propertyData?.ownerWillingToRent ? 'Yes' : 'No',
|
|
||||||
expectedRent: data.propertyData?.expectedMonthlyRentAmount || '',
|
|
||||||
|
|
||||||
schoolDistrict: data.propertyData?.schoolDistrict || '',
|
|
||||||
neighborhoodRating: data.propertyData?.neighborhoodRating || 5,
|
|
||||||
|
|
||||||
occupation: data.ownerData?.occupation || '',
|
|
||||||
ownershipType: data.ownerData?.ownershipType || 'Individual',
|
|
||||||
willingToSell: data.ownerData?.willingToSellProperty ? 'Yes' : 'No',
|
|
||||||
desiredPrice: data.ownerData?.desiredSellingPrice || '',
|
|
||||||
minPrice: data.ownerData?.minimumAcceptableSellingPrice || '',
|
|
||||||
ownerAddress: data.ownerData?.mailingAddress || '',
|
|
||||||
|
|
||||||
// Map Insurance Data
|
|
||||||
insurance_company: data.insuranceData?.insurance_company || '',
|
|
||||||
insurance_company_not_listed: data.insuranceData?.insurance_company_not_listed || false,
|
|
||||||
damage_location: data.insuranceData?.damage_location || '',
|
|
||||||
date_of_loss: data.insuranceData?.date_of_loss || '',
|
|
||||||
claim_filed: data.insuranceData?.claim_filed ? 'Yes' : 'No',
|
|
||||||
claim_number: data.insuranceData?.claim_number || '',
|
|
||||||
has_paperwork: data.insuranceData?.has_paperwork ? 'Yes' : 'No',
|
|
||||||
adjuster_name: data.insuranceData?.adjuster_name || '',
|
|
||||||
adjuster_phone: data.insuranceData?.adjuster_phone || '',
|
|
||||||
adjuster_ext: data.insuranceData?.adjuster_ext || '',
|
|
||||||
adjuster_type: data.insuranceData?.adjuster_type || '',
|
|
||||||
adjuster_fax: data.insuranceData?.adjuster_fax || '',
|
|
||||||
adjuster_email: data.insuranceData?.adjuster_email || '',
|
|
||||||
met_with_adjuster: data.insuranceData?.met_with_adjuster ? 'Yes' : 'No',
|
|
||||||
claim_approved: data.insuranceData?.claim_approved ? 'Yes' : 'No',
|
|
||||||
});
|
|
||||||
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 z-[1000] flex flex-col transition-transform duration-300 ease-out inset-0 md:inset-auto md:top-4 md:right-4 md:bottom-4 md:w-[500px] ${isOpen ? 'translate-x-0' : 'translate-x-full md: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-2 md:grid-cols-3 gap-2">
|
|
||||||
{["Neutral", "Hot Lead", "Renovated", "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-1 md: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-1 md:grid-cols-3 gap-2 col-span-1 md: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-1 md: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">
|
|
||||||
<h4 className="text-[10px] uppercase font-bold text-purple-600 dark:text-purple-400 border-b border-purple-200 dark:border-purple-500/20 pb-1 mb-2">Tenant Profile</h4>
|
|
||||||
<RenderInput label="Full Name *" field="tenantName" placeholder="Required" />
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<RenderInput label="Phone *" field="tenantPhone" placeholder="Required" />
|
|
||||||
<RenderInput label="Email" field="tenantEmail" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<RenderInput label="Occupation" field="tenantOccupation" />
|
|
||||||
<RenderInput label="Employer" field="tenantEmployer" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<RenderInput label="Annual Income" field="tenantIncome" />
|
|
||||||
<RenderInput label="Family Status" field="livingStatus" options={["Single", "Family", "Couple", "Roommates"]} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 className="text-[10px] uppercase font-bold text-purple-600 dark:text-purple-400 border-b border-purple-200 dark:border-purple-500/20 pb-1 mb-2 mt-4">Lease Details</h4>
|
|
||||||
<RenderInput label="Lease Type" field="leaseType" options={["Residential Standard", "Commercial NNN", "Short Term", "Month-to-Month"]} />
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
|
||||||
<RenderInput label="Signed" field="leaseSigned" type="date" />
|
|
||||||
<RenderInput label="Start" field="leaseStart" type="date" />
|
|
||||||
<RenderInput label="End" field="leaseEnd" type="date" />
|
|
||||||
</div>
|
|
||||||
<RenderInput label="Monthly Rent ($)" field="currentRent" type="number" />
|
|
||||||
</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: INSURANCE INFORMATION */}
|
|
||||||
<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">
|
|
||||||
<ShieldCheck size={16} /> <h3 className="text-xs font-black uppercase tracking-widest">Insurance Information</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<RenderInput label="Insurance Company" field="insurance_company" options={["State Farm", "Allstate", "Liberty Mutual", "Farmers", "Nationwide", "USAA", "Chubb", "Travelers", "Progressive", "American Family", "Other"]} />
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.insurance_company_not_listed}
|
|
||||||
onChange={(e) => isEditing && setFormData({ ...formData, insurance_company_not_listed: e.target.checked })}
|
|
||||||
disabled={!isEditing}
|
|
||||||
className="rounded border-zinc-300 dark:border-white/10"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-zinc-500">Company Not Listed</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<RenderInput label="Claim Filed?" field="claim_filed" options={["Yes", "No"]} />
|
|
||||||
<RenderInput label="Date of Loss" field="date_of_loss" type="date" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.claim_filed === 'Yes' && (
|
|
||||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/10 rounded-xl space-y-4 border border-blue-100 dark:border-blue-500/20">
|
|
||||||
<RenderInput label="Damage Location" field="damage_location" />
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<RenderInput label="Claim Number" field="claim_number" />
|
|
||||||
<RenderInput label="Has Paperwork?" field="has_paperwork" options={["Yes", "No"]} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 className="text-[10px] uppercase font-bold text-blue-600 dark:text-blue-400 border-b border-blue-200 dark:border-blue-500/20 pb-1 mb-2 mt-2">Adjuster Details</h4>
|
|
||||||
<RenderInput label="Adjuster Name" field="adjuster_name" />
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
|
||||||
<RenderInput label="Phone" field="adjuster_phone" />
|
|
||||||
<RenderInput label="Ext" field="adjuster_ext" />
|
|
||||||
<RenderInput label="Fax" field="adjuster_fax" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<RenderInput label="Email" field="adjuster_email" />
|
|
||||||
<RenderInput label="Type" field="adjuster_type" options={["Staff Adjuster", "Independent Adjuster", "Public Adjuster"]} />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<RenderInput label="Met Adjuster?" field="met_with_adjuster" options={["Yes", "No"]} />
|
|
||||||
<RenderInput label="Claim Approved?" field="claim_approved" options={["Yes", "No"]} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SECTION 5: 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-1 md: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-1 md: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-1 md: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 --- //
|
// --- MAIN PAGE COMPONENT --- //
|
||||||
|
|
||||||
function Maps() {
|
function Maps() {
|
||||||
@@ -662,8 +188,6 @@ function Maps() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Selection State
|
// Selection State
|
||||||
const [selectedPropertyId, setSelectedPropertyId] = useState(null);
|
const [selectedPropertyId, setSelectedPropertyId] = useState(null);
|
||||||
const [newPropertyLocation, setNewPropertyLocation] = useState(null); // { lat, lng, address }
|
const [newPropertyLocation, setNewPropertyLocation] = useState(null); // { lat, lng, address }
|
||||||
@@ -798,55 +322,54 @@ function Maps() {
|
|||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
canvassingStatus: formData.status,
|
canvassingStatus: formData.status,
|
||||||
ownerData: { ...p.ownerData, ...newOwnerData, details: { ...p.ownerData.details, ...newOwnerData.details } },
|
ownerData: { ...p.ownerData, ...newOwnerData },
|
||||||
propertyData: {
|
propertyData: { ...p.propertyData, ...newPropertyData.details, ...newPropertyData }, // Merge Flattened and Nested
|
||||||
...p.propertyData,
|
insuranceData: { ...p.insuranceData, ...newInsuranceData }
|
||||||
...newPropertyData,
|
|
||||||
details: { ...p.propertyData.details, ...newPropertyData.details } // Merge to keep existing data not in form if any
|
|
||||||
},
|
|
||||||
insuranceData: newInsuranceData
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return p;
|
return p;
|
||||||
}));
|
}));
|
||||||
|
toast.success("Property updated successfully");
|
||||||
} else if (newPropertyLocation) {
|
} else if (newPropertyLocation) {
|
||||||
// Create New
|
// Create New
|
||||||
const newId = `prop-${Date.now()}`;
|
const newId = properties.length + 1;
|
||||||
const newProp = {
|
// Generate Polygon (Simple Box around center) as placeholder
|
||||||
id: newId,
|
// In real app, user would draw. Here we just spawn a box.
|
||||||
polygon: [], // We don't have a polygon drawing tool yet, implies point-based or empty
|
const d = 0.0001;
|
||||||
canvassingStatus: formData.status,
|
const newPoly = [
|
||||||
ownerData: newOwnerData,
|
[newPropertyLocation.lat + d, newPropertyLocation.lng - d],
|
||||||
insuranceData: newInsuranceData,
|
[newPropertyLocation.lat + d, newPropertyLocation.lng + d],
|
||||||
propertyData: {
|
[newPropertyLocation.lat - d, newPropertyLocation.lng + d],
|
||||||
...newPropertyData,
|
[newPropertyLocation.lat - d, newPropertyLocation.lng - d],
|
||||||
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]);
|
const newItem = {
|
||||||
setSelectedPropertyId(newId); // Select the new item
|
id: newId,
|
||||||
setNewPropertyLocation(null); // Clear creation mode
|
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
|
||||||
}
|
}
|
||||||
toast.success("Property data saved successfully");
|
};
|
||||||
} catch (error) {
|
setProperties(prev => [...prev, newItem]);
|
||||||
logger.error("Error saving property data", error);
|
toast.success("New property added to map");
|
||||||
toast.error("Failed to save changes", { description: "Please try again." });
|
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." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -861,13 +384,13 @@ function Maps() {
|
|||||||
/>
|
/>
|
||||||
<MapLegend />
|
<MapLegend />
|
||||||
|
|
||||||
<Drawer
|
<PropertyDetailDrawer
|
||||||
isOpen={!!selectedPropertyId || !!newPropertyLocation}
|
isOpen={!!selectedProperty || !!newPropertyLocation}
|
||||||
onClose={() => { setSelectedPropertyId(null); setNewPropertyLocation(null); }}
|
onClose={() => { setSelectedPropertyId(null); setNewPropertyLocation(null); }}
|
||||||
data={selectedProperty}
|
data={selectedProperty}
|
||||||
newLocation={newPropertyLocation}
|
newLocation={newPropertyLocation}
|
||||||
onSave={handleSave}
|
|
||||||
loadingAddr={loadingAddr}
|
loadingAddr={loadingAddr}
|
||||||
|
onSave={handleSave}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||