mowing and properties

This commit is contained in:
Jake Kasper
2025-09-03 10:28:24 -04:00
parent 1c6ab9e569
commit fe6f7f3858
4 changed files with 219 additions and 15 deletions

View File

@@ -83,8 +83,15 @@ router.post('/sessions', validateRequest(mowingSessionSchema), async (req, res,
// GET /api/mowing/sessions - list sessions for user // GET /api/mowing/sessions - list sessions for user
router.get('/sessions', async (req, res, next) => { router.get('/sessions', async (req, res, next) => {
try { 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( 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, STRING_AGG(ls.name, ', ') as section_names,
SUM(ls.area) as total_area SUM(ls.area) as total_area
FROM mowing_sessions ms 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 LEFT JOIN user_equipment ue ON ms.equipment_id=ue.id
JOIN mowing_session_sections lss ON lss.session_id = ms.id JOIN mowing_session_sections lss ON lss.session_id = ms.id
JOIN lawn_sections ls ON lss.lawn_section_id = ls.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 GROUP BY ms.id, p.name, ue.custom_name
ORDER BY ms.created_at DESC ORDER BY ms.created_at DESC
LIMIT 200`, LIMIT 200`,
[req.user.id] params
); );
res.json({ success: true, data: { sessions: result.rows } }); res.json({ success: true, data: { sessions: result.rows } });
} catch (error) { } catch (error) {
@@ -109,7 +116,7 @@ router.get('/sessions/:id', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const sessionRes = await pool.query( 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 FROM mowing_sessions ms
JOIN properties p ON ms.property_id=p.id JOIN properties p ON ms.property_id=p.id
LEFT JOIN user_equipment ue ON ms.equipment_id=ue.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 // Alias /logs to sessions to be consistent with Applications
router.get('/logs', async (req, res, next) => { router.get('/logs', async (req, res, next) => {
try { 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( 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, STRING_AGG(ls.name, ', ') as section_names,
SUM(ls.area) as total_area SUM(ls.area) as total_area
FROM mowing_sessions ms 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 LEFT JOIN user_equipment ue ON ms.equipment_id=ue.id
JOIN mowing_session_sections lss ON lss.session_id = ms.id JOIN mowing_session_sections lss ON lss.session_id = ms.id
JOIN lawn_sections ls ON lss.lawn_section_id = ls.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 GROUP BY ms.id, p.name, ue.custom_name
ORDER BY ms.created_at DESC ORDER BY ms.created_at DESC
LIMIT 200`, [req.user.id]); LIMIT 200`, params);
res.json({ success: true, data: { logs: rs.rows } }); res.json({ success: true, data: { logs: rs.rows } });
} catch (error) { next(error); } } catch (error) { next(error); }
}); });

View File

@@ -373,11 +373,10 @@ const History = () => {
const meters = (log.total_distance_meters || log.gpsTrack?.totalDistance || log.gps_track?.totalDistance || 0) || 0; const meters = (log.total_distance_meters || log.gpsTrack?.totalDistance || log.gps_track?.totalDistance || 0) || 0;
const totalDistanceFeet = meters * 3.28084; const totalDistanceFeet = meters * 3.28084;
const plannedArea = Number(log.total_area || 0); const plannedArea = Number(log.total_area || 0);
if (totalDistanceFeet === 0 || plannedArea === 0) return 0; const widthInches = parseFloat(log.cutting_width_inches || 0);
let equipmentWidth = 6; // default mower deck width in ft const widthFeet = isNaN(widthInches) ? 0 : (widthInches / 12);
const name = (log.equipment_name || '').toLowerCase(); if (totalDistanceFeet === 0 || plannedArea === 0 || widthFeet === 0) return 0;
if (name.includes('30"') || name.includes('30 in')) equipmentWidth = 2.5; const theoreticalCoverageArea = totalDistanceFeet * widthFeet;
const theoreticalCoverageArea = totalDistanceFeet * equipmentWidth;
return Math.min(100, Math.round((theoreticalCoverageArea / plannedArea) * 100)); return Math.min(100, Math.round((theoreticalCoverageArea / plannedArea) * 100));
}; };

View File

@@ -11,7 +11,10 @@ import {
SwatchIcon, SwatchIcon,
PencilIcon PencilIcon
} from '@heroicons/react/24/outline'; } 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 LoadingSpinner from '../../components/UI/LoadingSpinner';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
@@ -302,10 +305,45 @@ const PropertyDetail = () => {
const [editingSection, setEditingSection] = useState(null); const [editingSection, setEditingSection] = useState(null);
const [showEditModal, setShowEditModal] = useState(false); 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(() => { useEffect(() => {
fetchPropertyDetails(); fetchPropertyDetails();
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps }, [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 // Handle keyboard shortcuts for polygon drawing
useEffect(() => { useEffect(() => {
const handleKeyPress = (e) => { const handleKeyPress = (e) => {
@@ -494,6 +532,55 @@ const PropertyDetail = () => {
return lawnSections.reduce((total, section) => total + section.area, 0); 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) { if (loading) {
return ( return (
<div className="p-6"> <div className="p-6">
@@ -704,6 +791,95 @@ const PropertyDetail = () => {
</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 */} {/* Name Modal */}
{showNameModal && ( {showNameModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style={{ zIndex: 9999 }}> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style={{ zIndex: 9999 }}>
@@ -806,8 +982,23 @@ const PropertyDetail = () => {
</div> </div>
</div> </div>
)} )}
{/* View Modals for History */}
{viewingApplication && (
<ApplicationViewModal
application={viewingApplication}
propertyDetails={property}
onClose={() => setViewingApplication(null)}
/>
)}
{viewingMowingSession && (
<MowingSessionViewModal
session={viewingMowingSession}
onClose={() => setViewingMowingSession(null)}
/>
)}
</div> </div>
); );
}; };
export default PropertyDetail; export default PropertyDetail;

View File

@@ -244,7 +244,7 @@ export const mowingAPI = {
updatePlanStatus: (id, status) => apiClient.put(`/mowing/plans/${id}/status`, { status }), updatePlanStatus: (id, status) => apiClient.put(`/mowing/plans/${id}/status`, { status }),
// Logs/Sessions // Logs/Sessions
createLog: (data) => apiClient.post('/mowing/sessions', data), 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}`), getSession: (id) => apiClient.get(`/mowing/sessions/${id}`),
}; };