Files
turftracker/frontend/src/pages/Properties/PropertyDetail.js
Jake Kasper e7cbaf844f seed stuff
2025-09-03 10:56:17 -04:00

1087 lines
43 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { MapContainer, TileLayer, Marker, Popup, Polygon, useMapEvents, useMap } from 'react-leaflet';
import { Icon } from 'leaflet';
import * as turf from '@turf/turf';
import {
PlusIcon,
TrashIcon,
ArrowLeftIcon,
MapPinIcon,
SwatchIcon,
PencilIcon
} from '@heroicons/react/24/outline';
import { propertiesAPI, applicationsAPI, mowingAPI } from '../../services/api';
import ApplicationViewModal from '../../components/Applications/ApplicationViewModal';
import MowingSessionViewModal from '../../components/Mowing/MowingSessionViewModal';
import { EyeIcon, CalendarIcon, WrenchScrewdriverIcon } from '@heroicons/react/24/outline';
import LoadingSpinner from '../../components/UI/LoadingSpinner';
import toast from 'react-hot-toast';
import 'leaflet/dist/leaflet.css';
// Simple tag input and suggestion components for grass types
const COOL_SEASON_GRASSES = [
'Turf Type Tall Fescue',
'Kentucky Bluegrass',
'Perennial Ryegrass',
'Fine Fescue',
'Creeping Red Fescue',
'Chewings Fescue',
'Hard Fescue',
'Annual Ryegrass'
];
const TagInput = ({ value = [], onChange }) => {
const [input, setInput] = useState('');
const add = (val) => { const v = val.trim(); if (!v) return; if (!value.includes(v)) onChange([...(value||[]), v]); setInput(''); };
return (
<div className="border rounded p-2">
<div className="flex flex-wrap gap-2 mb-2">
{(value||[]).map((t) => (
<span key={t} className="px-2 py-1 bg-gray-100 rounded text-xs flex items-center gap-1">
{t}
<button className="text-gray-500 hover:text-gray-700" onClick={() => onChange((value||[]).filter(x=>x!==t))}>×</button>
</span>
))}
</div>
<input
className="w-full border-0 focus:outline-none text-sm"
placeholder="Type and press Enter to add"
value={input}
onChange={(e)=> setInput(e.target.value)}
onKeyDown={(e)=> { if (e.key==='Enter'){ e.preventDefault(); add(input); } }}
/>
</div>
);
};
const SuggestionChips = ({ onPick }) => (
<div className="flex flex-wrap gap-2 mt-2">
{COOL_SEASON_GRASSES.map(g => (
<button key={g} type="button" onClick={()=> onPick(g)} className="px-2 py-1 bg-blue-50 hover:bg-blue-100 text-blue-700 rounded text-xs">
{g}
</button>
))}
</div>
);
// Fix for default markers
delete Icon.Default.prototype._getIconUrl;
Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});
// Colors for lawn sections
const SECTION_COLORS = [
{ name: 'Green', value: '#22c55e' },
{ name: 'Blue', value: '#3b82f6' },
{ name: 'Red', value: '#ef4444' },
{ name: 'Yellow', value: '#eab308' },
{ name: 'Purple', value: '#a855f7' },
{ name: 'Orange', value: '#f97316' },
];
// Calculate area in square feet
const calculateAreaInSqFt = (coordinates) => {
try {
const polygon = turf.polygon([coordinates.map(coord => [coord[1], coord[0]])]);
const areaInSqMeters = turf.area(polygon);
return Math.round(areaInSqMeters * 10.7639); // Convert to sq ft
} catch (error) {
console.error('Area calculation error:', error);
return 0;
}
};
// Calculate distance from a point to a line segment
const pointToLineDistance = (point, lineStart, lineEnd) => {
const [px, py] = point;
const [x1, y1] = lineStart;
const [x2, y2] = lineEnd;
const A = px - x1;
const B = py - y1;
const C = x2 - x1;
const D = y2 - y1;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
if (lenSq === 0) return Math.sqrt(A * A + B * B);
let param = dot / lenSq;
let xx, yy;
if (param < 0) {
xx = x1;
yy = y1;
} else if (param > 1) {
xx = x2;
yy = y2;
} else {
xx = x1 + param * C;
yy = y1 + param * D;
}
const dx = px - xx;
const dy = py - yy;
return Math.sqrt(dx * dx + dy * dy);
};
// Component for drawing polygons
function PolygonDrawer({ isDrawing, onPolygonComplete, currentColor }) {
const [currentPolygon, setCurrentPolygon] = useState([]);
useMapEvents({
click(e) {
if (!isDrawing) return;
const newPoint = [e.latlng.lat, e.latlng.lng];
console.log('Adding point:', newPoint);
setCurrentPolygon(prev => {
const newPoly = [...prev, newPoint];
console.log('Current polygon has', newPoly.length, 'points');
return newPoly;
});
},
dblclick(e) {
console.log('Double click detected, drawing:', isDrawing, 'points:', currentPolygon.length);
if (isDrawing && currentPolygon.length >= 3) {
e.originalEvent.preventDefault();
e.originalEvent.stopPropagation();
console.log('Completing polygon with', currentPolygon.length, 'points');
onPolygonComplete(currentPolygon);
setCurrentPolygon([]);
}
}
});
return currentPolygon.length > 0 ? (
<Polygon
positions={currentPolygon}
pathOptions={{
color: currentColor.value,
fillColor: currentColor.value,
fillOpacity: 0.3,
weight: 2,
dashArray: '5, 5'
}}
/>
) : null;
}
// Component for editable polygons
function EditablePolygon({ section, onUpdate, onEdit, onDelete }) {
// Import toast function from the module scope
const [isEditing, setIsEditing] = useState(false);
const [editedCoordinates, setEditedCoordinates] = useState(section.coordinates);
const map = useMap();
const handleMarkerDrag = (index, newLatLng) => {
const newCoords = [...editedCoordinates];
newCoords[index] = [newLatLng.lat, newLatLng.lng];
setEditedCoordinates(newCoords);
// Recalculate area
const area = calculateAreaInSqFt([...newCoords, newCoords[0]]);
onUpdate(section.id, { ...section, coordinates: newCoords, area });
};
const addPointAtIndex = (index, e) => {
e.originalEvent.stopPropagation();
const newCoords = [...editedCoordinates];
const newPoint = [e.latlng.lat, e.latlng.lng];
newCoords.splice(index + 1, 0, newPoint);
setEditedCoordinates(newCoords);
const area = calculateAreaInSqFt([...newCoords, newCoords[0]]);
onUpdate(section.id, { ...section, coordinates: newCoords, area });
};
const removePoint = (index) => {
if (editedCoordinates.length <= 3) {
toast.error('Polygon must have at least 3 points');
return;
}
const newCoords = editedCoordinates.filter((_, i) => i !== index);
setEditedCoordinates(newCoords);
const area = calculateAreaInSqFt([...newCoords, newCoords[0]]);
onUpdate(section.id, { ...section, coordinates: newCoords, area });
};
return (
<>
<Polygon
positions={editedCoordinates}
pathOptions={{
color: section.color.value,
fillColor: section.color.value,
fillOpacity: isEditing ? 0.3 : 0.4,
weight: isEditing ? 3 : 2
}}
eventHandlers={{
click: (e) => {
if (!isEditing) {
setIsEditing(true);
toast('Edit mode enabled: Drag points to move, right-click to remove points, click edges to add points');
} else {
// Add point when clicking on polygon edge during edit mode
const newPoint = [e.latlng.lat, e.latlng.lng];
// Find the closest edge and insert the point there
let closestEdgeIndex = 0;
let minDistance = Infinity;
for (let i = 0; i < editedCoordinates.length; i++) {
const start = editedCoordinates[i];
const end = editedCoordinates[(i + 1) % editedCoordinates.length];
// Calculate distance from clicked point to edge
const distance = pointToLineDistance(newPoint, start, end);
if (distance < minDistance) {
minDistance = distance;
closestEdgeIndex = i;
}
}
// Insert new point after the closest edge start point
const newCoords = [...editedCoordinates];
newCoords.splice(closestEdgeIndex + 1, 0, newPoint);
setEditedCoordinates(newCoords);
const area = calculateAreaInSqFt([...newCoords, newCoords[0]]);
onUpdate(section.id, { ...section, coordinates: newCoords, area });
toast('Point added! Drag it to adjust position');
}
}
}}
>
<Popup>
<div className="text-center">
<strong>{section.name}</strong><br />
{section.area.toLocaleString()} sq ft<br />
<div className="flex gap-2 mt-2">
<button
onClick={() => {
setIsEditing(!isEditing);
toast(isEditing ? 'Edit mode disabled' : 'Edit mode enabled');
}}
className={`text-sm ${isEditing ? 'text-green-600' : 'text-blue-600'}`}
title={isEditing ? 'Exit edit mode' : 'Drag corners, click edges to add points, right-click to remove'}
>
{isEditing ? 'Done' : 'Edit Points'}
</button>
<button
onClick={() => onEdit(section)}
className="text-blue-600 text-sm"
>
Edit Name
</button>
<button
onClick={() => onDelete(section.id)}
className="text-red-600 text-sm"
>
Delete
</button>
</div>
</div>
</Popup>
</Polygon>
{/* Editable markers for each point */}
{isEditing && editedCoordinates.map((coord, index) => (
<Marker
key={`${section.id}-${index}`}
position={coord}
draggable={true}
eventHandlers={{
dragend: (e) => {
handleMarkerDrag(index, e.target.getLatLng());
},
contextmenu: (e) => {
e.originalEvent.preventDefault();
if (editedCoordinates.length > 3) {
removePoint(index);
}
}
}}
icon={new Icon({
iconUrl: 'data:image/svg+xml;base64,' + btoa(`
<svg width="12" height="12" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
<circle cx="6" cy="6" r="5" fill="${section.color.value}" stroke="white" stroke-width="2"/>
</svg>
`),
iconSize: [12, 12],
iconAnchor: [6, 6]
})}
>
<Popup>
<div className="text-center">
<p className="text-xs">Point {index + 1}</p>
<button
onClick={() => removePoint(index)}
className="text-red-600 text-xs"
disabled={editedCoordinates.length <= 3}
>
Remove Point
</button>
</div>
</Popup>
</Marker>
))}
</>
);
}
const PropertyDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const [property, setProperty] = useState(null);
const [lawnSections, setLawnSections] = useState([]);
const [loading, setLoading] = useState(true);
const [isDrawing, setIsDrawing] = useState(false);
const [currentColor, setCurrentColor] = useState(SECTION_COLORS[0]);
const [showNameModal, setShowNameModal] = useState(false);
const [pendingSection, setPendingSection] = useState(null);
const [sectionName, setSectionName] = useState('');
const [sectionGrassTypes, setSectionGrassTypes] = useState([]);
const [editingSection, setEditingSection] = useState(null);
const [showEditModal, setShowEditModal] = useState(false);
const [editGrassTypes, setEditGrassTypes] = useState([]);
// Recent history state for this property
const [completedApplications, setCompletedApplications] = useState([]);
const [applicationLogs, setApplicationLogs] = useState([]);
const [mowingLogs, setMowingLogs] = useState([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [viewingApplication, setViewingApplication] = useState(null);
const [viewingMowingSession, setViewingMowingSession] = useState(null);
useEffect(() => {
fetchPropertyDetails();
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
// Load recent history when property is available
useEffect(() => {
if (!property?.id) return;
const loadHistory = async () => {
try {
setHistoryLoading(true);
const [completedRes, archivedRes, logsRes, mowRes] = await Promise.all([
applicationsAPI.getPlans({ status: 'completed', property_id: property.id }),
applicationsAPI.getPlans({ status: 'archived', property_id: property.id }),
applicationsAPI.getLogs({ property_id: property.id }),
mowingAPI.getLogs({ property_id: property.id })
]);
const completedPlans = completedRes.data?.data?.plans || [];
const archivedPlans = archivedRes.data?.data?.plans || [];
setCompletedApplications([...(completedPlans||[]), ...(archivedPlans||[])]);
setApplicationLogs(logsRes.data?.data?.logs || []);
setMowingLogs(mowRes.data?.data?.logs || []);
} catch (e) {
console.warn('Failed to load property history', e?.response?.data || e.message);
setCompletedApplications([]);
setApplicationLogs([]);
setMowingLogs([]);
} finally { setHistoryLoading(false); }
};
loadHistory();
}, [property?.id]);
// Handle keyboard shortcuts for polygon drawing
useEffect(() => {
const handleKeyPress = (e) => {
if (isDrawing && e.key === 'Enter') {
// Find the PolygonDrawer component and complete the polygon
console.log('Enter pressed during drawing mode');
}
if (e.key === 'Escape' && isDrawing) {
setIsDrawing(false);
toast('Drawing cancelled');
}
};
document.addEventListener('keydown', handleKeyPress);
return () => document.removeEventListener('keydown', handleKeyPress);
}, [isDrawing]);
const fetchPropertyDetails = async () => {
try {
setLoading(true);
const response = await propertiesAPI.getById(id);
console.log('Property details:', response);
const propertyData = response.data.data.property;
setProperty(propertyData);
// Convert backend sections to frontend format
if (propertyData.sections && propertyData.sections.length > 0) {
const convertedSections = propertyData.sections.map(section => ({
id: section.id,
name: section.name,
coordinates: section.polygonData?.coordinates?.[0] || [],
color: section.polygonData?.color || SECTION_COLORS[0],
area: section.area,
grassType: section.grassType || '',
grassTypes: section.grassTypes || null
}));
setLawnSections(convertedSections);
}
} catch (error) {
console.error('Failed to fetch property:', error);
toast.error('Failed to load property');
navigate('/properties');
} finally {
setLoading(false);
}
};
const handlePolygonComplete = (coordinates) => {
console.log('handlePolygonComplete called with', coordinates.length, 'coordinates');
if (coordinates.length < 3) {
toast.error('Polygon needs at least 3 points');
return;
}
const area = calculateAreaInSqFt([...coordinates, coordinates[0]]);
console.log('Calculated area:', area);
setPendingSection({ coordinates, color: currentColor, area });
setShowNameModal(true);
setIsDrawing(false);
};
const saveLawnSection = async () => {
if (!sectionName.trim()) {
toast.error('Please enter a section name');
return;
}
try {
const sectionData = {
name: sectionName,
area: pendingSection.area,
polygonData: {
coordinates: [pendingSection.coordinates],
color: pendingSection.color
},
grassType: sectionGrassTypes.join(', '),
grassTypes: sectionGrassTypes,
soilType: null
};
const response = await propertiesAPI.createSection(id, sectionData);
const savedSection = response.data.data.section;
// Convert backend format to frontend format
const newSection = {
id: savedSection.id,
name: savedSection.name,
coordinates: savedSection.polygonData.coordinates[0],
color: savedSection.polygonData.color,
area: savedSection.area
};
setLawnSections(prev => [...prev, newSection]);
toast.success(`${sectionName} section created and saved!`);
// Reset and cycle color
setSectionName('');
setSectionGrassTypes([]);
setPendingSection(null);
setShowNameModal(false);
const nextIndex = (SECTION_COLORS.findIndex(c => c.value === currentColor.value) + 1) % SECTION_COLORS.length;
setCurrentColor(SECTION_COLORS[nextIndex]);
} catch (error) {
console.error('Failed to save section:', error);
toast.error('Failed to save section. Please try again.');
}
};
const deleteLawnSection = async (sectionId) => {
if (window.confirm('Delete this lawn section?')) {
try {
await propertiesAPI.deleteSection(id, sectionId);
setLawnSections(prev => prev.filter(s => s.id !== sectionId));
toast.success('Section deleted');
} catch (error) {
console.error('Failed to delete section:', error);
toast.error('Failed to delete section. Please try again.');
}
}
};
const startEditSection = (section) => {
setEditingSection(section);
setSectionName(section.name);
setCurrentColor(section.color);
setShowEditModal(true);
const existing = (section.grassType || '').split(',').map(s=>s.trim()).filter(Boolean);
setEditGrassTypes(existing);
};
const saveEditedSection = async () => {
if (!sectionName.trim()) {
toast.error('Please enter a section name');
return;
}
try {
const sectionData = {
name: sectionName,
area: editingSection.area,
polygonData: {
coordinates: [editingSection.coordinates],
color: currentColor
},
grassType: editGrassTypes.join(', '),
grassTypes: editGrassTypes,
soilType: null
};
await propertiesAPI.updateSection(id, editingSection.id, sectionData);
const updatedSection = {
...editingSection,
name: sectionName,
color: currentColor
};
setLawnSections(prev => prev.map(s => s.id === editingSection.id ? updatedSection : s));
toast.success('Section updated and saved!');
// Reset
setSectionName('');
setEditingSection(null);
setShowEditModal(false);
setEditGrassTypes([]);
} catch (error) {
console.error('Failed to update section:', error);
toast.error('Failed to update section. Please try again.');
}
};
const updateSection = async (sectionId, updatedSection) => {
try {
const sectionData = {
name: updatedSection.name,
area: updatedSection.area,
polygonData: {
coordinates: [updatedSection.coordinates],
color: updatedSection.color
},
grassType: (updatedSection.grassType || ''),
grassTypes: (updatedSection.grassTypes || null),
soilType: null
};
await propertiesAPI.updateSection(id, sectionId, sectionData);
setLawnSections(prev => prev.map(s => s.id === sectionId ? updatedSection : s));
} catch (error) {
console.error('Failed to update section coordinates:', error);
toast.error('Failed to save polygon changes. Please try again.');
}
};
const getTotalArea = () => {
return lawnSections.reduce((total, section) => total + section.area, 0);
};
// History helpers
const getApplicationType = (app) => {
if (!app.productDetails || app.productDetails.length === 0) return null;
return app.productDetails[0].type;
};
const calculateCoverage = (application, log) => {
if (!log?.gpsTrack?.points && !log?.gps_track?.points) return 0;
const storedMeters = typeof (log.gpsTrack?.totalDistance || log.gps_track?.totalDistance) === 'number' ? (log.gpsTrack?.totalDistance || log.gps_track?.totalDistance) : 0;
const totalDistanceFeet = storedMeters * 3.28084;
const plannedArea = application.totalSectionArea || application.total_section_area || 0;
if (totalDistanceFeet === 0 || plannedArea === 0) return 0;
let equipmentWidth = 4;
const equipmentName = (application.equipmentName || '').toLowerCase();
if (equipmentName.includes('spreader')) equipmentWidth = 12;
else if (equipmentName.includes('sprayer')) equipmentWidth = 20;
else if (equipmentName.includes('mower')) equipmentWidth = 6;
const theoreticalCoverageArea = totalDistanceFeet * equipmentWidth;
return Math.min(Math.round((theoreticalCoverageArea / plannedArea) * 100), 100);
};
const calculateMowingCoverage = (log) => {
const meters = (log.total_distance_meters || log.gpsTrack?.totalDistance || log.gps_track?.totalDistance || 0) || 0;
const totalDistanceFeet = meters * 3.28084;
const plannedArea = Number(log.total_area || 0);
const widthInches = parseFloat(log.cutting_width_inches || 0);
const widthFeet = isNaN(widthInches) ? 0 : (widthInches / 12);
if (totalDistanceFeet === 0 || plannedArea === 0 || widthFeet === 0) return 0;
const theoreticalCoverageArea = totalDistanceFeet * widthFeet;
return Math.min(100, Math.round((theoreticalCoverageArea / plannedArea) * 100));
};
const unifiedHistory = (() => {
const apps = (completedApplications||[]).map((application) => {
const log = applicationLogs.find((l) => l.planId === application.id);
const dateStr = log?.applicationDate || application.plannedDate || application.updatedAt || application.createdAt;
const date = dateStr ? new Date(dateStr) : new Date(0);
return { kind: 'application', date, application, log };
});
const mows = (mowingLogs||[]).map((log) => {
const dateStr = log.session_date || log.created_at;
const date = dateStr ? new Date(dateStr) : new Date(0);
return { kind: 'mowing', date, log };
});
const items = [...apps, ...mows];
items.sort((a,b)=> b.date - a.date);
return items.slice(0, 10);
})();
if (loading) {
return (
<div className="p-6">
<div className="flex justify-center items-center h-64">
<LoadingSpinner size="lg" />
</div>
</div>
);
}
if (!property) {
return (
<div className="p-6">
<div className="card text-center py-12">
<h3 className="text-lg font-medium text-gray-900 mb-2">Property Not Found</h3>
<button onClick={() => navigate('/properties')} className="btn-primary">
Back to Properties
</button>
</div>
</div>
);
}
// Validate coordinates and provide fallbacks
const hasValidCoordinates = property &&
property.latitude !== undefined &&
property.longitude !== undefined &&
property.latitude !== null &&
property.longitude !== null &&
!isNaN(property.latitude) &&
!isNaN(property.longitude);
const mapCenter = hasValidCoordinates
? [property.latitude, property.longitude]
: [40.7128, -74.0060]; // Default to NYC
return (
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/properties')}
className="p-2 hover:bg-gray-100 rounded-lg"
>
<ArrowLeftIcon className="h-5 w-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">{property.name}</h1>
<p className="text-gray-600 flex items-center gap-1">
<MapPinIcon className="h-4 w-4" />
{property.address || 'No address specified'}
</p>
{!hasValidCoordinates && (
<p className="text-red-600 text-sm"> Property coordinates not set - using default location</p>
)}
</div>
</div>
<button
onClick={() => setIsDrawing(!isDrawing)}
className={`btn-primary flex items-center gap-2 ${isDrawing ? 'bg-red-600 hover:bg-red-700' : ''}`}
>
<PlusIcon className="h-5 w-5" />
{isDrawing ? 'Cancel Drawing' : 'Add Lawn Section'}
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Map */}
<div className="lg:col-span-3">
<div className="card p-0 overflow-hidden">
<div style={{ height: '600px', width: '100%' }}>
<MapContainer
center={mapCenter}
zoom={hasValidCoordinates ? 18 : 13}
maxZoom={19}
style={{ height: '100%', width: '100%' }}
>
<TileLayer
attribution='&copy; <a href="https://www.esri.com/">Esri</a>'
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
maxZoom={19}
/>
{hasValidCoordinates && (
<Marker position={mapCenter}>
<Popup>{property.name}</Popup>
</Marker>
)}
{lawnSections.map((section) => (
<EditablePolygon
key={section.id}
section={section}
onUpdate={updateSection}
onEdit={startEditSection}
onDelete={deleteLawnSection}
/>
))}
{isDrawing && (
<PolygonDrawer
isDrawing={isDrawing}
onPolygonComplete={handlePolygonComplete}
currentColor={currentColor}
/>
)}
</MapContainer>
</div>
{isDrawing && (
<div className="p-4 bg-blue-50 border-t">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-blue-600 rounded-full animate-pulse"></div>
<p className="text-sm text-blue-800">
<strong>Drawing Mode Active:</strong> Click to add points. Double-click to complete polygon.
</p>
</div>
<p className="text-xs text-blue-600 mt-1">
Need at least 3 points to create a section. Press ESC to cancel.
</p>
<p className="text-xs text-blue-500 mt-1">
💡 After creating: Click any polygon to edit its points by dragging
</p>
</div>
)}
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Color Selector */}
{isDrawing && (
<div className="card">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<SwatchIcon className="h-5 w-5" />
Section Color
</h3>
<div className="grid grid-cols-3 gap-2">
{SECTION_COLORS.map((color) => (
<button
key={color.value}
onClick={() => setCurrentColor(color)}
className={`w-10 h-10 rounded border-2 ${
currentColor.value === color.value ? 'border-gray-900' : 'border-gray-300'
}`}
style={{ backgroundColor: color.value }}
title={color.name}
/>
))}
</div>
</div>
)}
{/* Property Summary */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Property Summary</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Total Sections:</span>
<span className="font-medium">{lawnSections.length}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Total Area:</span>
<span className="font-medium">{getTotalArea().toLocaleString()} sq ft</span>
</div>
</div>
</div>
{/* Lawn Sections */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Lawn Sections</h3>
{lawnSections.length === 0 ? (
<p className="text-gray-500 text-sm">No sections yet.</p>
) : (
<div className="space-y-2">
{lawnSections.map((section) => (
<div key={section.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: section.color.value }}
/>
<div>
<p className="font-medium text-sm">{section.name}</p>
<p className="text-xs text-gray-600">{section.area.toLocaleString()} sq ft</p>
{(section.grassTypes && section.grassTypes.length > 0) || section.grassType ? (
<div className="flex flex-wrap gap-1 mt-1">
{((section.grassTypes && section.grassTypes.length > 0) ? section.grassTypes : (section.grassType||'').split(',').map(s=>s.trim()).filter(Boolean)).map((g, idx)=>(
<span key={idx} className="px-1.5 py-0.5 bg-green-100 text-green-800 rounded text-[10px]">{g}</span>
))}
</div>
) : null}
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => startEditSection(section)}
className="text-blue-600 hover:text-blue-800"
>
<PencilIcon className="h-4 w-4" />
</button>
<button
onClick={() => deleteLawnSection(section.id)}
className="text-red-600 hover:text-red-800"
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* Recent History */}
<div className="mt-8">
<h2 className="text-lg font-semibold mb-3">Recent History</h2>
{historyLoading ? (
<div className="card p-4"><div className="text-gray-600">Loading</div></div>
) : unifiedHistory.length === 0 ? (
<div className="card p-4 text-gray-600">No history yet for this property.</div>
) : (
<div className="grid gap-4">
{unifiedHistory.map((item) => {
if (item.kind === 'application') {
const application = item.application; const log = item.log;
return (
<div key={`app-${application.id}`} className="bg-white p-6 rounded-lg shadow">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-gray-900">{application.propertyName} - {application.sectionNames}</h3>
<span className="px-2 py-0.5 text-xs rounded-full bg-indigo-100 text-indigo-800">Application</span>
</div>
<span className={`px-3 py-1 text-sm font-medium rounded-full ${application.status==='archived'?'bg-gray-100 text-gray-800':'bg-green-100 text-green-800'}`}>{application.status==='archived'?'Archived':'Completed'}</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="flex items-center text-sm text-gray-600"><CalendarIcon className="h-4 w-4 mr-2" />{new Date(item.date).toLocaleString()}</div>
<div className="flex items-center text-sm text-gray-600"><MapPinIcon className="h-4 w-4 mr-2" />{application.propertyName}</div>
<div className="flex items-center text-sm text-gray-600"><WrenchScrewdriverIcon className="h-4 w-4 mr-2" />{application.equipmentName}</div>
</div>
{log?.gpsTrack && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
<div className="bg-blue-50 p-2 rounded text-center">
<div className="text-xs text-blue-600 font-medium">Duration</div>
<div className="text-sm font-bold text-blue-900">{Math.round((log.gpsTrack?.duration||0)/60)} min</div>
</div>
<div className="bg-green-50 p-2 rounded text-center">
<div className="text-xs text-green-600 font-medium">GPS Points</div>
<div className="text-sm font-bold text-green-900">{log.gpsTrack?.points?.length || 0}</div>
</div>
<div className="bg-purple-50 p-2 rounded text-center">
<div className="text-xs text-purple-600 font-medium">Distance</div>
<div className="text-sm font-bold text-purple-900">{Math.round(log.gpsTrack?.totalDistance||0)} ft</div>
</div>
<div className="bg-orange-50 p-2 rounded text-center">
<div className="text-xs text-orange-600 font-medium">Coverage</div>
<div className="text-sm font-bold text-orange-900">{calculateCoverage(application, log)}%</div>
</div>
</div>
)}
</div>
<button onClick={()=> setViewingApplication(application)} className="p-2 text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded" title="View details"><EyeIcon className="h-5 w-5" /></button>
</div>
</div>
);
}
const log = item.log;
const durationMin = Math.round((log.duration_seconds || log.durationSeconds || log.gpsTrack?.duration || log.gps_track?.duration || 0)/60);
const distFeet = Math.round(((log.total_distance_meters || log.gpsTrack?.totalDistance || log.gps_track?.totalDistance || 0)*3.28084)||0);
return (
<div key={`mow-${log.id}`} className="bg-white p-6 rounded-lg shadow">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-gray-900">{log.property_name} - {log.section_names}</h3>
<span className="px-2 py-0.5 text-xs rounded-full bg-green-100 text-green-800">Mowing</span>
</div>
<span className="px-3 py-1 text-sm font-medium rounded-full bg-green-100 text-green-800">Completed</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="flex items-center text-sm text-gray-600"><CalendarIcon className="h-4 w-4 mr-2" />{new Date(item.date).toLocaleString()}</div>
<div className="flex items-center text-sm text-gray-600"><MapPinIcon className="h-4 w-4 mr-2" />{log.property_name}</div>
<div className="flex items-center text-sm text-gray-600"><WrenchScrewdriverIcon className="h-4 w-4 mr-2" />{log.equipment_name || 'Mower'}</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
<div className="bg-blue-50 p-2 rounded text-center"><div className="text-xs text-blue-600 font-medium">Duration</div><div className="text-sm font-bold text-blue-900">{durationMin} min</div></div>
<div className="bg-green-50 p-2 rounded text-center"><div className="text-xs text-green-600 font-medium">GPS Points</div><div className="text-sm font-bold text-green-900">{log.gpsTrack?.points?.length || log.gps_track?.points?.length || 0}</div></div>
<div className="bg-purple-50 p-2 rounded text-center"><div className="text-xs text-purple-600 font-medium">Distance</div><div className="text-sm font-bold text-purple-900">{distFeet} ft</div></div>
<div className="bg-orange-50 p-2 rounded text-center"><div className="text-xs text-orange-600 font-medium">Coverage</div><div className="text-sm font-bold text-orange-900">{calculateMowingCoverage(log)}%</div></div>
</div>
</div>
<button onClick={()=> setViewingMowingSession(log)} className="p-2 text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded" title="View mowing session"><EyeIcon className="h-5 w-5" /></button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Name Modal */}
{showNameModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style={{ zIndex: 9999 }}>
<div className="bg-white rounded-lg p-6 w-96 shadow-2xl">
<h3 className="text-lg font-semibold mb-4">Name Your Lawn Section</h3>
<div className="space-y-4">
<input
type="text"
className="input"
value={sectionName}
onChange={(e) => setSectionName(e.target.value)}
placeholder="e.g., Front Yard, Back Lawn"
autoFocus
/>
<div className="flex items-center gap-2 text-sm">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: pendingSection?.color.value }}
/>
<span>{pendingSection?.area.toLocaleString()} sq ft</span>
</div>
<div>
<label className="label">Grass Types</label>
<TagInput
value={sectionGrassTypes}
onChange={setSectionGrassTypes}
/>
<SuggestionChips onPick={(v)=> setSectionGrassTypes(prev=> prev.includes(v)? prev : [...prev, v])} />
</div>
</div>
<div className="flex gap-3 mt-6">
<button onClick={saveLawnSection} className="btn-primary flex-1">
Save
</button>
<button
onClick={() => {
setShowNameModal(false);
setPendingSection(null);
setSectionName('');
setSectionGrassTypes([]);
}}
className="btn-secondary flex-1"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{showEditModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style={{ zIndex: 9999 }}>
<div className="bg-white rounded-lg p-6 w-96 shadow-2xl">
<h3 className="text-lg font-semibold mb-4">Edit Lawn Section</h3>
<div className="space-y-4">
<div>
<label className="label">Section Name</label>
<input
type="text"
className="input"
value={sectionName}
onChange={(e) => setSectionName(e.target.value)}
placeholder="e.g., Front Yard, Back Lawn"
autoFocus
/>
</div>
<div>
<label className="label">Color</label>
<div className="grid grid-cols-3 gap-2">
{SECTION_COLORS.map((color) => (
<button
key={color.value}
onClick={() => setCurrentColor(color)}
className={`w-10 h-10 rounded border-2 ${
currentColor.value === color.value ? 'border-gray-900' : 'border-gray-300'
}`}
style={{ backgroundColor: color.value }}
title={color.name}
/>
))}
</div>
</div>
<div>
<label className="label">Grass Types</label>
<TagInput
value={editGrassTypes}
onChange={setEditGrassTypes}
/>
<SuggestionChips onPick={(v)=> setEditGrassTypes(prev=> prev.includes(v)? prev : [...prev, v])} />
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: currentColor.value }}
/>
<span>{editingSection?.area.toLocaleString()} sq ft</span>
</div>
</div>
<div className="flex gap-3 mt-6">
<button onClick={saveEditedSection} className="btn-primary flex-1">
Save Changes
</button>
<button
onClick={() => {
setShowEditModal(false);
setEditingSection(null);
setSectionName('');
setEditGrassTypes([]);
}}
className="btn-secondary flex-1"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* View Modals for History */}
{viewingApplication && (
<ApplicationViewModal
application={viewingApplication}
propertyDetails={property}
onClose={() => setViewingApplication(null)}
/>
)}
{viewingMowingSession && (
<MowingSessionViewModal
session={viewingMowingSession}
onClose={() => setViewingMowingSession(null)}
/>
)}
</div>
);
};
export default PropertyDetail;