From 948b2a9f6271abbfab7748be75cda53f465c6d9c Mon Sep 17 00:00:00 2001 From: anibilag Date: Sun, 6 Jul 2025 23:30:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=B0=D1=8F=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=8E=D1=89=D0=B0=D1=8F=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=B8=D1=8F=200.1.0.=20=D0=94=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B3=D1=80=D1=83=D0=BF?= =?UTF-8?q?=D0=BF=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=81=D0=B5=D1=81=D1=81=D0=B8?= =?UTF-8?q?=D0=B8.=20=D0=A2=D1=80=D0=B5=D0=B1=D1=83=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D1=83=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=80=D1=83=D1=81=D1=81=D0=BA=D0=B8=D0=B9=20=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=BE=D0=B2.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lawn_scheduler.db | Bin 12288 -> 20480 bytes package.json | 2 +- server/index.js | 560 ++++++++++++++++++++++++++-- src/components/BulkMowingModal.tsx | 358 ++++++++++++++++++ src/components/Dashboard.tsx | 161 +++++++-- src/components/HistoryView.tsx | 562 +++++++++++++++++++++++++++++ src/components/Icons.tsx | 199 ++++++---- src/components/MowingModal.tsx | 211 +++++++++++ src/components/Pagination.tsx | 147 ++++++++ src/components/SitePlan.tsx | 20 +- src/components/ZoneCard.tsx | 66 +++- src/components/ZoneForm.tsx | 159 ++++++-- src/services/api.ts | 80 +++- src/types/zone.ts | 102 +++++- 14 files changed, 2441 insertions(+), 186 deletions(-) create mode 100644 src/components/BulkMowingModal.tsx create mode 100644 src/components/HistoryView.tsx create mode 100644 src/components/MowingModal.tsx create mode 100644 src/components/Pagination.tsx diff --git a/lawn_scheduler.db b/lawn_scheduler.db index c50dcb36b3823bb7b9d0b6230bd7eb231d91d25f..f668e8ef8b32614d58a9b60de32d94c3c8805369 100644 GIT binary patch literal 20480 zcmeI4Pi)&%9LJxVZfV`b*$PvbMv*5#YgiNP*h$*3Y0Fx-R7;bRrV{HVN>kHCOB0r) z>k887R`!Q6X^0(=7y?a0LL88)vBLheSU+y(|xp{0u*uNGl^siXTsnj>S^P z_@l}(J~o+7jwZl;Oi85sjF}&lit4C6JCRKDiOG1pW`uLAGecSU|8ZJ*7Yq%?VyUWD z3`1@5%zQap&6kQqxsUhz`FxR|%NOTirlGc=W~;~5a-CPM3`6QntXh*8Qifxb@iad; znMy&7ne^zGGLeprjrZY1)pA{^DP>rJIfKeX%~aug^D{m^nc#<%xB`486f+mTqE;#p z893uSYT*tiQ_AQ_!r;^ zwlyyhSe|-eirKlA*sF{0w;Z1?XN&du&6Q@ zE(Fe<`wQ*C#d@A0xm(40?rp~D^?I3)ONPmyXnbjFfMU`$isH$Obu%XUx$LYu4y9F> zZy{T$jy2S8Ezf+hs+Lb>3q#q(3Mo~qvMnf)+T<)$Clp02ZW)B9rq#z6)zdf>>y6aM zS8~VInfZd6wic7D#y%3?KHqrTcJKcE%rOIG3U)^#!~*%OBCjfzK3N%5L`@a&?_S+_d|#r`vPE{dL#xU1!{~>#^%s*JW4y`S}S% zGo=J50ZM=ppaduZN`Mle1paRV85fIs&_RZg4)$oD>*uux+U=ka4EcqKUy#${KvaxG zWjP@B3sYQ&ZQ(^BDofE&m<);ijg!e$)QK(@g^0j)Z50I8^>g|aIJ%{+Yb(TmgV@Wq z#SjZdWq}NBCU#&aiz0B1#Ym_}yQ$sR?&ue^6~Fd{wxaz4&)d6($teUGpEVSUN?{V} zfgP>Aztxv&QSW-C$yF5%!Wp zQBe%^i}Dornr*=nvK$S`WJok5?8!u;upO_fS>xBV6!#uo8P9PWwL0L<3 zARH8@xHoKzp%w&`WXM#rCKEN!0y@(3mQnR)F|??9u2prDGvqpONtp58fEr%XR-ulr z5M?vW{-_`Y1|q>J&ShJSF!4K<44GkClZgm?uy~SaFO(~ASkq2-vM6b5u0ciNqQ9-( z!EaK(_MNth!+^C}*RPUun}G~Oh5mpj!0NncTZ}-UX8XyI8HhER1mcEB9G>5h=WoxS z@Qpqw0ZM=ppaduZN`Mle1SkPYfD)htC;>{~Ss~EvaQGPL#j(0l%8MOrn`1W~gI=7r zPR8M7j24ugZqLui>3Ybx4|@`>huk0Bhg`S&`)4Il8Yd+{2~Yx*03|>PPy&n;iinBC4dNZSs676z4>1VL1O?BVlAPju7)kp8SM{E8Esf2adw-n)Y+&cTRY+% zo2FE&20U>qrCEn9R!pf>Ii;vRWErJoy=03|>P zPy&)GS$l$3&+tCsUQ9{-^R*q84>BLjN}A;udZe-3UtxCJNL5Y-RxE2~vV0mu44KZ~d8 US4PUJT3jfVPgLrhpCFij0E}I?X#fBK delta 326 zcmZozz}S#5L0XW7fq{V;h+%+nqK+|8P_JkKFHneyZ#DzpTfW(w1r@aUCeP*zVrdd! zWZ(RePk<35&CIXIz`ujPoL>*9K!LyBotc9{nZcHk-8eWiB~`&au_z@q&&a^YRM)^v z*T^!&(Adh@$ja10&%(?+iq9eF0@zlB0tJ`?dnOJBd7wc|#!iXJ*$Rkr6U{C}*#jPkmuLQ*iBO_EJ*d3B^x&-7+M*go1{9l1yJi= -1 + isDueToday: daysUntilNext <= 0 && daysUntilNext >= -1, + isNew: false }; } +// Helper function to calculate next mow date for interval scheduling +function calculateNextMowDate(lastMowedDate, intervalDays) { + if (!lastMowedDate || !intervalDays) return null; + const lastMowed = new Date(lastMowedDate); + const nextMow = new Date(lastMowed.getTime() + (intervalDays * 24 * 60 * 60 * 1000)); + return nextMow.toISOString(); +} + +// Helper function to generate session ID +function generateSessionId() { + return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +// Mower routes +app.get('/api/mowers', async (req, res) => { + try { + const result = await db.execute('SELECT * FROM mowers WHERE isActive = 1 ORDER BY name'); + const mowers = result.rows.map(row => ({ + id: row.id, + name: row.name, + type: row.type, + brand: row.brand, + model: row.model, + isActive: row.isActive, + createdAt: row.createdAt + })); + res.json(mowers); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/mowers', async (req, res) => { + try { + const { name, type, brand, model } = req.body; + + const result = await db.execute({ + sql: 'INSERT INTO mowers (name, type, brand, model) VALUES (?, ?, ?, ?)', + args: [name, type, brand || null, model || null] + }); + + const newMowerResult = await db.execute({ + sql: 'SELECT * FROM mowers WHERE id = ?', + args: [result.lastInsertRowid] + }); + + const newMower = { + id: newMowerResult.rows[0].id, + name: newMowerResult.rows[0].name, + type: newMowerResult.rows[0].type, + brand: newMowerResult.rows[0].brand, + model: newMowerResult.rows[0].model, + isActive: newMowerResult.rows[0].isActive, + createdAt: newMowerResult.rows[0].createdAt + }; + + res.status(201).json(newMower); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // Routes app.get('/api/zones', async (req, res) => { try { @@ -141,6 +333,8 @@ app.get('/api/zones', async (req, res) => { imagePath: row.imagePath, lastMowedDate: row.lastMowedDate, intervalDays: row.intervalDays, + nextMowDate: row.nextMowDate, + scheduleType: row.scheduleType || 'interval', area: row.area || 0, createdAt: row.createdAt })); @@ -168,6 +362,8 @@ app.get('/api/zones/:id', async (req, res) => { imagePath: result.rows[0].imagePath, lastMowedDate: result.rows[0].lastMowedDate, intervalDays: result.rows[0].intervalDays, + nextMowDate: result.rows[0].nextMowDate, + scheduleType: result.rows[0].scheduleType || 'interval', area: result.rows[0].area || 0, createdAt: result.rows[0].createdAt }; @@ -179,16 +375,213 @@ app.get('/api/zones/:id', async (req, res) => { } }); +// Get mowing history for all zones or specific zone with pagination +app.get('/api/history', async (req, res) => { + try { + const { zoneId, limit = 10, offset = 0 } = req.query; + + // Get total count for pagination + let countSql = ` + SELECT COUNT(*) as total + FROM mowing_history mh + JOIN zones z ON mh.zoneId = z.id + LEFT JOIN mowers m ON mh.mowerId = m.id + `; + let countArgs = []; + + if (zoneId) { + countSql += ' WHERE mh.zoneId = ?'; + countArgs.push(zoneId); + } + + const countResult = await db.execute({ sql: countSql, args: countArgs }); + const total = countResult.rows[0].total; + + // Get paginated results + let sql = ` + SELECT + mh.*, + z.name as zoneName, + z.area as zoneArea, + m.name as mowerName, + m.type as mowerType + FROM mowing_history mh + JOIN zones z ON mh.zoneId = z.id + LEFT JOIN mowers m ON mh.mowerId = m.id + `; + let args = []; + + if (zoneId) { + sql += ' WHERE mh.zoneId = ?'; + args.push(zoneId); + } + + sql += ' ORDER BY mh.mowedDate DESC LIMIT ? OFFSET ?'; + args.push(parseInt(limit), parseInt(offset)); + + const result = await db.execute({ sql, args }); + + const history = result.rows.map(row => ({ + id: row.id, + zoneId: row.zoneId, + zoneName: row.zoneName, + zoneArea: row.zoneArea, + mowerId: row.mowerId, + mowerName: row.mowerName, + mowerType: row.mowerType, + mowedDate: row.mowedDate, + notes: row.notes, + duration: row.duration, + weather: row.weather, + sessionId: row.sessionId, + createdAt: row.createdAt + })); + + // Calculate pagination info + const currentPage = Math.floor(parseInt(offset) / parseInt(limit)) + 1; + const totalPages = Math.ceil(total / parseInt(limit)); + const hasNextPage = currentPage < totalPages; + const hasPrevPage = currentPage > 1; + + res.json({ + data: history, + pagination: { + total, + currentPage, + totalPages, + hasNextPage, + hasPrevPage, + limit: parseInt(limit), + offset: parseInt(offset) + } + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Get mowing statistics +app.get('/api/history/stats', async (req, res) => { + try { + const { period = '30' } = req.query; // days + const periodDays = parseInt(period); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - periodDays); + + // Total mowing sessions + const totalResult = await db.execute({ + sql: ` + SELECT COUNT(*) as total FROM ( + SELECT 1 FROM mowing_history + WHERE mowedDate >= ? AND sessionId IS NULL + UNION ALL + SELECT 1 FROM mowing_history + WHERE mowedDate >= ? AND sessionId IS NOT NULL + GROUP BY sessionId + ) AS combined + `, + args: [startDate.toISOString(), startDate.toISOString()] + }); + + // Total time spent + const timeResult = await db.execute({ + sql: 'SELECT SUM(duration) as totalMinutes FROM mowing_history WHERE mowedDate >= ? AND duration IS NOT NULL', + args: [startDate.toISOString()] + }); + + // Total area mowed + const areaResult = await db.execute({ + sql: ` + SELECT SUM(z.area) as totalArea + FROM mowing_history mh + JOIN zones z ON mh.zoneId = z.id + WHERE mh.mowedDate >= ? + `, + args: [startDate.toISOString()] + }); + + // Most active zone + const activeZoneResult = await db.execute({ + sql: ` + SELECT z.name, COUNT(*) as sessions + FROM mowing_history mh + JOIN zones z ON mh.zoneId = z.id + WHERE mh.mowedDate >= ? + GROUP BY mh.zoneId, z.name + ORDER BY sessions DESC + LIMIT 1 + `, + args: [startDate.toISOString()] + }); + + // Most used mower + const activeMowerResult = await db.execute({ + sql: ` + SELECT m.name, m.type, COUNT(*) as sessions + FROM mowing_history mh + JOIN mowers m ON mh.mowerId = m.id + WHERE mh.mowedDate >= ? + GROUP BY mh.mowerId, m.name, m.type + ORDER BY sessions DESC + LIMIT 1 + `, + args: [startDate.toISOString()] + }); + + // Daily activity for chart + const dailyResult = await db.execute({ + sql: ` + SELECT + DATE(mowedDate) as date, + COUNT(*) as sessions, + SUM(duration) as totalDuration, + SUM(z.area) as totalArea + FROM mowing_history mh + JOIN zones z ON mh.zoneId = z.id + WHERE mowedDate >= ? + GROUP BY DATE(mowedDate) + ORDER BY date DESC + LIMIT 30 + `, + args: [startDate.toISOString()] + }); + + res.json({ + period: periodDays, + totalSessions: totalResult.rows[0].total, + totalMinutes: timeResult.rows[0].totalMinutes || 0, + totalArea: areaResult.rows[0].totalArea || 0, + mostActiveZone: activeZoneResult.rows[0] || null, + mostUsedMower: activeMowerResult.rows[0] || null, + dailyActivity: dailyResult.rows.map(row => ({ + date: row.date, + sessions: row.sessions, + duration: row.totalDuration || 0, + area: row.totalArea || 0 + })) + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + app.post('/api/zones', upload.single('image'), async (req, res) => { try { - const { name, intervalDays, area } = req.body; + const { name, intervalDays, nextMowDate, scheduleType, area } = req.body; const imagePath = req.file ? `/uploads/${req.file.filename}` : null; - const lastMowedDate = new Date().toISOString(); - const result = await db.execute({ - sql: 'INSERT INTO zones (name, imagePath, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?, ?)', - args: [name, imagePath, lastMowedDate, parseInt(intervalDays), parseFloat(area) || 0] - }); + // For new zones, don't set lastMowedDate (they haven't been mowed yet) + let sql, args; + + if (scheduleType === 'specific') { + sql = 'INSERT INTO zones (name, imagePath, nextMowDate, scheduleType, area) VALUES (?, ?, ?, ?, ?)'; + args = [name, imagePath, nextMowDate, scheduleType, parseFloat(area) || 0]; + } else { + sql = 'INSERT INTO zones (name, imagePath, intervalDays, scheduleType, area) VALUES (?, ?, ?, ?, ?)'; + args = [name, imagePath, parseInt(intervalDays), scheduleType || 'interval', parseFloat(area) || 0]; + } + + const result = await db.execute({ sql, args }); const newZoneResult = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', @@ -201,6 +594,8 @@ app.post('/api/zones', upload.single('image'), async (req, res) => { imagePath: newZoneResult.rows[0].imagePath, lastMowedDate: newZoneResult.rows[0].lastMowedDate, intervalDays: newZoneResult.rows[0].intervalDays, + nextMowDate: newZoneResult.rows[0].nextMowDate, + scheduleType: newZoneResult.rows[0].scheduleType || 'interval', area: newZoneResult.rows[0].area || 0, createdAt: newZoneResult.rows[0].createdAt }; @@ -214,7 +609,7 @@ app.post('/api/zones', upload.single('image'), async (req, res) => { app.put('/api/zones/:id', upload.single('image'), async (req, res) => { try { - const { name, intervalDays, lastMowedDate, area } = req.body; + const { name, intervalDays, lastMowedDate, nextMowDate, scheduleType, area } = req.body; const existingResult = await db.execute({ sql: 'SELECT * FROM zones WHERE id = ?', @@ -228,10 +623,17 @@ app.put('/api/zones/:id', upload.single('image'), async (req, res) => { const existingZone = existingResult.rows[0]; const imagePath = req.file ? `/uploads/${req.file.filename}` : existingZone.imagePath; - await db.execute({ - sql: 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, intervalDays = ?, area = ? WHERE id = ?', - args: [name, imagePath, lastMowedDate, parseInt(intervalDays), parseFloat(area) || 0, req.params.id] - }); + let sql, args; + + if (scheduleType === 'specific') { + sql = 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, nextMowDate = ?, scheduleType = ?, intervalDays = NULL, area = ? WHERE id = ?'; + args = [name, imagePath, lastMowedDate || null, nextMowDate, scheduleType, parseFloat(area) || 0, req.params.id]; + } else { + sql = 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, intervalDays = ?, scheduleType = ?, nextMowDate = NULL, area = ? WHERE id = ?'; + args = [name, imagePath, lastMowedDate || null, parseInt(intervalDays), scheduleType || 'interval', parseFloat(area) || 0, req.params.id]; + } + + await db.execute({ sql, args }); // Delete old image if new one was provided if (req.file && existingZone.imagePath) { @@ -252,6 +654,8 @@ app.put('/api/zones/:id', upload.single('image'), async (req, res) => { imagePath: updatedResult.rows[0].imagePath, lastMowedDate: updatedResult.rows[0].lastMowedDate, intervalDays: updatedResult.rows[0].intervalDays, + nextMowDate: updatedResult.rows[0].nextMowDate, + scheduleType: updatedResult.rows[0].scheduleType || 'interval', area: updatedResult.rows[0].area || 0, createdAt: updatedResult.rows[0].createdAt }; @@ -297,11 +701,40 @@ app.delete('/api/zones/:id', async (req, res) => { app.post('/api/zones/:id/mow', async (req, res) => { try { + const { notes, duration, weather, mowerId } = req.body; const today = new Date().toISOString(); + // Get current zone data + const zoneResult = await db.execute({ + sql: 'SELECT * FROM zones WHERE id = ?', + args: [req.params.id] + }); + + if (zoneResult.rows.length === 0) { + return res.status(404).json({ error: 'Zone not found' }); + } + + const zone = zoneResult.rows[0]; + + // Update zone's last mowed date and calculate next mow date if using interval scheduling + let sql, args; + + if (zone.scheduleType === 'interval' && zone.intervalDays) { + const nextMowDate = calculateNextMowDate(today, zone.intervalDays); + sql = 'UPDATE zones SET lastMowedDate = ?, nextMowDate = ? WHERE id = ?'; + args = [today, nextMowDate, req.params.id]; + } else { + // For specific date scheduling, just update last mowed date + sql = 'UPDATE zones SET lastMowedDate = ? WHERE id = ?'; + args = [today, req.params.id]; + } + + await db.execute({ sql, args }); + + // Add to mowing history await db.execute({ - sql: 'UPDATE zones SET lastMowedDate = ? WHERE id = ?', - args: [today, req.params.id] + sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather) VALUES (?, ?, ?, ?, ?, ?)', + args: [req.params.id, mowerId || null, today, notes || null, duration || null, weather || null] }); const updatedResult = await db.execute({ @@ -315,6 +748,8 @@ app.post('/api/zones/:id/mow', async (req, res) => { imagePath: updatedResult.rows[0].imagePath, lastMowedDate: updatedResult.rows[0].lastMowedDate, intervalDays: updatedResult.rows[0].intervalDays, + nextMowDate: updatedResult.rows[0].nextMowDate, + scheduleType: updatedResult.rows[0].scheduleType || 'interval', area: updatedResult.rows[0].area || 0, createdAt: updatedResult.rows[0].createdAt }; @@ -326,6 +761,79 @@ app.post('/api/zones/:id/mow', async (req, res) => { } }); +// Bulk mowing endpoint +app.post('/api/zones/bulk-mow', async (req, res) => { + try { + const { selectedZoneIds, notes, totalDuration, weather, mowerId } = req.body; + + if (!selectedZoneIds || selectedZoneIds.length === 0) { + return res.status(400).json({ error: 'No zones selected' }); + } + + const today = new Date().toISOString(); + const sessionId = generateSessionId(); + + // Get zone data for selected zones + const zoneResults = await Promise.all( + selectedZoneIds.map(id => + db.execute({ + sql: 'SELECT * FROM zones WHERE id = ?', + args: [id] + }) + ) + ); + + const zones = zoneResults.map(result => result.rows[0]).filter(Boolean); + + if (zones.length === 0) { + return res.status(404).json({ error: 'No valid zones found' }); + } + + // Calculate total area for proportional time distribution + const totalArea = zones.reduce((sum, zone) => sum + (zone.area || 0), 0); + + // Process each zone + for (const zone of zones) { + // Calculate proportional duration + let zoneDuration = null; + if (totalDuration && totalArea > 0 && zone.area > 0) { + const proportion = zone.area / totalArea; + zoneDuration = Math.round(totalDuration * proportion); + } + + // Update zone's last mowed date and calculate next mow date if using interval scheduling + let sql, args; + + if (zone.scheduleType === 'interval' && zone.intervalDays) { + const nextMowDate = calculateNextMowDate(today, zone.intervalDays); + sql = 'UPDATE zones SET lastMowedDate = ?, nextMowDate = ? WHERE id = ?'; + args = [today, nextMowDate, zone.id]; + } else { + // For specific date scheduling, just update last mowed date + sql = 'UPDATE zones SET lastMowedDate = ? WHERE id = ?'; + args = [today, zone.id]; + } + + await db.execute({ sql, args }); + + // Add to mowing history with session ID + await db.execute({ + sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather, sessionId) VALUES (?, ?, ?, ?, ?, ?, ?)', + args: [zone.id, mowerId || null, today, notes || null, zoneDuration, weather || null, sessionId] + }); + } + + res.json({ + success: true, + message: `Successfully recorded mowing session for ${zones.length} zones`, + sessionId, + zonesUpdated: zones.length + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // Serve React app in production if (process.env.NODE_ENV === 'production') { app.get('*', (req, res) => { diff --git a/src/components/BulkMowingModal.tsx b/src/components/BulkMowingModal.tsx new file mode 100644 index 0000000..a490a43 --- /dev/null +++ b/src/components/BulkMowingModal.tsx @@ -0,0 +1,358 @@ +import React, { useState, useEffect } from 'react'; +import { X, Timer, Cloud, FileText, Scissors, CheckCircle, Square } from './Icons'; +import { Zone, Mower, BulkMowingFormData } from '../types/zone'; +import { api } from '../services/api'; + +interface BulkMowingModalProps { + zones: Zone[]; + onSubmit: (data: BulkMowingFormData) => void; + onCancel: () => void; + loading?: boolean; +} + +const BulkMowingModal: React.FC = ({ + zones, + onSubmit, + onCancel, + loading = false +}) => { + const [formData, setFormData] = useState({ + selectedZoneIds: [], + notes: '', + totalDuration: undefined, + weather: '', + mowerId: undefined, + }); + const [mowers, setMowers] = useState([]); + const [loadingMowers, setLoadingMowers] = useState(true); + + useEffect(() => { + loadMowers(); + }, []); + + const loadMowers = async () => { + try { + const data = await api.getMowers(); + setMowers(data); + // Auto-select first mower if only one exists + if (data.length === 1) { + setFormData(prev => ({ ...prev, mowerId: data[0].id })); + } + } catch (error) { + console.error('Failed to load mowers:', error); + } finally { + setLoadingMowers(false); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (formData.selectedZoneIds.length === 0) { + alert('Please select at least one zone to mow.'); + return; + } + onSubmit(formData); + }; + + const handleZoneToggle = (zoneId: number) => { + setFormData(prev => ({ + ...prev, + selectedZoneIds: prev.selectedZoneIds.includes(zoneId) + ? prev.selectedZoneIds.filter(id => id !== zoneId) + : [...prev.selectedZoneIds, zoneId] + })); + }; + + const weatherOptions = [ + 'Солнечно', + 'Переменная облачность', + 'Облачно', + 'Пасмурно', + 'Небольшой дождь', + 'Ветренно', + 'Жарко', + 'Холодно', + 'Влажно' + ]; + + const getMowerIcon = (type: string) => { + switch (type.toLowerCase()) { + case 'battery': + return '🔋'; + case 'electric': + return '⚡'; + case 'gas': + case 'petrol': + return '⛽'; + default: + return '🚜'; + } + }; + + const getZoneStatusColor = (zone: Zone) => { + switch (zone.status) { + case 'overdue': + return 'border-red-200 bg-red-50'; + case 'due': + return 'border-orange-200 bg-orange-50'; + case 'new': + return 'border-blue-200 bg-blue-50'; + default: + return 'border-green-200 bg-green-50'; + } + }; + + const getZoneStatusText = (zone: Zone) => { + if (zone.isNew) { + return 'Еще не косилась'; + } else if (zone.isOverdue) { + return `${Math.abs(zone.daysUntilNext!)} дней посроченно`; + } else if (zone.isDueToday) { + return 'Срок - сегодня'; + } else { + return `${zone.daysUntilNext} дней осталось`; + } + }; + + const selectedZones = zones.filter(zone => formData.selectedZoneIds.includes(zone.id)); + const totalSelectedArea = selectedZones.reduce((sum, zone) => sum + zone.area, 0); + + // Calculate time distribution preview + const getTimeDistribution = () => { + if (!formData.totalDuration || totalSelectedArea === 0) return []; + + return selectedZones.map(zone => { + const proportion = zone.area / totalSelectedArea; + const allocatedTime = Math.round(formData.totalDuration! * proportion); + return { + zone, + proportion: proportion * 100, + allocatedTime + }; + }); + }; + + const timeDistribution = getTimeDistribution(); + + return ( +
+
+
+
+

+ + Сеанс массового скашивания +

+

+ Выберите несколько зон, скошенных за один сеанс +

+
+ +
+ +
+ {/* Zone Selection */} +
+ +
+ {zones.map(zone => ( +
handleZoneToggle(zone.id)} + > +
+
+

{zone.name}

+

+ {getZoneStatusText(zone)} +

+
+ + {zone.area.toLocaleString()} м2 +
+
+ {formData.selectedZoneIds.includes(zone.id) && ( + + )} +
+
+ ))} +
+ {formData.selectedZoneIds.length > 0 && ( +
+

+ Selected: {selectedZones.map(z => z.name).join(', ')} + + (Общая площадь: {totalSelectedArea.toLocaleString()} м2) + +

+
+ )} +
+ + {/* Session Details */} +
+ {/* Mower Selection */} +
+ + {loadingMowers ? ( +
+ Loading mowers... +
+ ) : ( + + )} +
+ + {/* Total Duration */} +
+ + setFormData(prev => ({ + ...prev, + totalDuration: e.target.value ? parseInt(e.target.value) : undefined + }))} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent" + min="1" + max="480" + placeholder="напр., 90" + /> +

+ Время будет распределено пропорционально площади зоны +

+
+ + {/* Weather */} +
+ + +
+ + {/* Notes */} +
+ +