From fe6f7f38584bfac29b5f6e6f282490d9da3e3ea4 Mon Sep 17 00:00:00 2001 From: Jake Kasper Date: Wed, 3 Sep 2025 10:28:24 -0400 Subject: [PATCH] mowing and properties --- backend/src/routes/mowing.js | 28 ++- frontend/src/pages/History/History.js | 9 +- .../src/pages/Properties/PropertyDetail.js | 195 +++++++++++++++++- frontend/src/services/api.js | 2 +- 4 files changed, 219 insertions(+), 15 deletions(-) diff --git a/backend/src/routes/mowing.js b/backend/src/routes/mowing.js index 6b5b78b..851209b 100644 --- a/backend/src/routes/mowing.js +++ b/backend/src/routes/mowing.js @@ -83,8 +83,15 @@ router.post('/sessions', validateRequest(mowingSessionSchema), async (req, res, // GET /api/mowing/sessions - list sessions for user router.get('/sessions', async (req, res, next) => { try { + const { property_id } = req.query; + const params = [req.user.id]; + let where = 'ms.user_id=$1'; + if (property_id) { + params.push(property_id); + where += ` AND p.id=$${params.length}`; + } const result = await pool.query( - `SELECT ms.*, p.name as property_name, ue.custom_name as equipment_name, + `SELECT ms.*, p.name as property_name, ue.custom_name as equipment_name, ue.cutting_width_inches, STRING_AGG(ls.name, ', ') as section_names, SUM(ls.area) as total_area FROM mowing_sessions ms @@ -92,11 +99,11 @@ router.get('/sessions', async (req, res, next) => { LEFT JOIN user_equipment ue ON ms.equipment_id=ue.id JOIN mowing_session_sections lss ON lss.session_id = ms.id JOIN lawn_sections ls ON lss.lawn_section_id = ls.id - WHERE ms.user_id=$1 + WHERE ${where} GROUP BY ms.id, p.name, ue.custom_name ORDER BY ms.created_at DESC LIMIT 200`, - [req.user.id] + params ); res.json({ success: true, data: { sessions: result.rows } }); } catch (error) { @@ -109,7 +116,7 @@ router.get('/sessions/:id', async (req, res, next) => { try { const { id } = req.params; const sessionRes = await pool.query( - `SELECT ms.*, p.name as property_name, ue.custom_name as equipment_name + `SELECT ms.*, p.name as property_name, ue.custom_name as equipment_name, ue.cutting_width_inches FROM mowing_sessions ms JOIN properties p ON ms.property_id=p.id LEFT JOIN user_equipment ue ON ms.equipment_id=ue.id @@ -218,8 +225,15 @@ router.put('/plans/:id/status', async (req, res, next) => { // Alias /logs to sessions to be consistent with Applications router.get('/logs', async (req, res, next) => { try { + const { property_id } = req.query; + const params = [req.user.id]; + let where = 'ms.user_id=$1'; + if (property_id) { + params.push(property_id); + where += ` AND p.id=$${params.length}`; + } const rs = await pool.query( - `SELECT ms.*, p.name as property_name, ue.custom_name as equipment_name, + `SELECT ms.*, p.name as property_name, ue.custom_name as equipment_name, ue.cutting_width_inches, STRING_AGG(ls.name, ', ') as section_names, SUM(ls.area) as total_area FROM mowing_sessions ms @@ -227,10 +241,10 @@ router.get('/logs', async (req, res, next) => { LEFT JOIN user_equipment ue ON ms.equipment_id=ue.id JOIN mowing_session_sections lss ON lss.session_id = ms.id JOIN lawn_sections ls ON lss.lawn_section_id = ls.id - WHERE ms.user_id=$1 + WHERE ${where} GROUP BY ms.id, p.name, ue.custom_name ORDER BY ms.created_at DESC - LIMIT 200`, [req.user.id]); + LIMIT 200`, params); res.json({ success: true, data: { logs: rs.rows } }); } catch (error) { next(error); } }); diff --git a/frontend/src/pages/History/History.js b/frontend/src/pages/History/History.js index 38f1708..c1f76e5 100644 --- a/frontend/src/pages/History/History.js +++ b/frontend/src/pages/History/History.js @@ -373,11 +373,10 @@ const History = () => { 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); - if (totalDistanceFeet === 0 || plannedArea === 0) return 0; - let equipmentWidth = 6; // default mower deck width in ft - const name = (log.equipment_name || '').toLowerCase(); - if (name.includes('30"') || name.includes('30 in')) equipmentWidth = 2.5; - const theoreticalCoverageArea = totalDistanceFeet * equipmentWidth; + 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)); }; diff --git a/frontend/src/pages/Properties/PropertyDetail.js b/frontend/src/pages/Properties/PropertyDetail.js index 6bfd518..2a63962 100644 --- a/frontend/src/pages/Properties/PropertyDetail.js +++ b/frontend/src/pages/Properties/PropertyDetail.js @@ -11,7 +11,10 @@ import { SwatchIcon, PencilIcon } from '@heroicons/react/24/outline'; -import { propertiesAPI } from '../../services/api'; +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'; @@ -302,10 +305,45 @@ const PropertyDetail = () => { const [editingSection, setEditingSection] = useState(null); const [showEditModal, setShowEditModal] = useState(false); + // 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) => { @@ -494,6 +532,55 @@ const PropertyDetail = () => { 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 (
@@ -704,6 +791,95 @@ const PropertyDetail = () => {
+ {/* Recent History */} +
+

Recent History

+ {historyLoading ? ( +
Loading…
+ ) : unifiedHistory.length === 0 ? ( +
No history yet for this property.
+ ) : ( +
+ {unifiedHistory.map((item) => { + if (item.kind === 'application') { + const application = item.application; const log = item.log; + return ( +
+
+
+
+
+

{application.propertyName} - {application.sectionNames}

+ Application +
+ {application.status==='archived'?'Archived':'Completed'} +
+
+
{new Date(item.date).toLocaleString()}
+
{application.propertyName}
+
{application.equipmentName}
+
+ {log?.gpsTrack && ( +
+
+
Duration
+
{Math.round((log.gpsTrack?.duration||0)/60)} min
+
+
+
GPS Points
+
{log.gpsTrack?.points?.length || 0}
+
+
+
Distance
+
{Math.round(log.gpsTrack?.totalDistance||0)} ft
+
+
+
Coverage
+
{calculateCoverage(application, log)}%
+
+
+ )} +
+ +
+
+ ); + } + 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 ( +
+
+
+
+
+

{log.property_name} - {log.section_names}

+ Mowing +
+ Completed +
+
+
{new Date(item.date).toLocaleString()}
+
{log.property_name}
+
{log.equipment_name || 'Mower'}
+
+
+
Duration
{durationMin} min
+
GPS Points
{log.gpsTrack?.points?.length || log.gps_track?.points?.length || 0}
+
Distance
{distFeet} ft
+
Coverage
{calculateMowingCoverage(log)}%
+
+
+ +
+
+ ); + })} +
+ )} +
+ {/* Name Modal */} {showNameModal && (
@@ -806,8 +982,23 @@ const PropertyDetail = () => {
)} + + {/* View Modals for History */} + {viewingApplication && ( + setViewingApplication(null)} + /> + )} + {viewingMowingSession && ( + setViewingMowingSession(null)} + /> + )} ); }; -export default PropertyDetail; \ No newline at end of file +export default PropertyDetail; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 5fda2b7..d0fdd16 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -244,7 +244,7 @@ export const mowingAPI = { updatePlanStatus: (id, status) => apiClient.put(`/mowing/plans/${id}/status`, { status }), // Logs/Sessions createLog: (data) => apiClient.post('/mowing/sessions', data), - getLogs: () => apiClient.get('/mowing/logs'), + getLogs: (params) => apiClient.get('/mowing/logs', { params }), getSession: (id) => apiClient.get(`/mowing/sessions/${id}`), };