Первая работающая версия 0.1.0. Добавлены групповые сессии. Требуется уточнить перевод на русский терминов.

This commit is contained in:
anibilag 2025-07-06 23:30:12 +03:00
parent 39c23eef2f
commit 948b2a9f62
14 changed files with 2441 additions and 186 deletions

Binary file not shown.

View File

@ -1,7 +1,7 @@
{ {
"name": "lawn-mowing-scheduler", "name": "lawn-mowing-scheduler",
"private": true, "private": true,
"version": "0.0.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently \"npm run server\" \"npm run client\"", "dev": "concurrently \"npm run server\" \"npm run client\"",

View File

@ -60,19 +60,64 @@ const db = createClient({
url: 'file:lawn_scheduler.db' url: 'file:lawn_scheduler.db'
}); });
// Create zones table with area field // Create mowers table
await db.execute(`
CREATE TABLE IF NOT EXISTS mowers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
brand TEXT,
model TEXT,
isActive BOOLEAN DEFAULT 1,
createdAt TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
// Create zones table with updated schema
await db.execute(` await db.execute(`
CREATE TABLE IF NOT EXISTS zones ( CREATE TABLE IF NOT EXISTS zones (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
imagePath TEXT, imagePath TEXT,
lastMowedDate TEXT, lastMowedDate TEXT,
intervalDays INTEGER NOT NULL DEFAULT 7, intervalDays INTEGER,
nextMowDate TEXT,
scheduleType TEXT DEFAULT 'interval',
area REAL DEFAULT 0, area REAL DEFAULT 0,
createdAt TEXT DEFAULT CURRENT_TIMESTAMP createdAt TEXT DEFAULT CURRENT_TIMESTAMP
) )
`); `);
// Create mowing history table
await db.execute(`
CREATE TABLE IF NOT EXISTS mowing_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
zoneId INTEGER NOT NULL,
mowerId INTEGER,
mowedDate TEXT NOT NULL,
notes TEXT,
duration INTEGER, -- in minutes
weather TEXT,
sessionId TEXT, -- for grouping bulk mowing sessions
createdAt TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (zoneId) REFERENCES zones (id) ON DELETE CASCADE,
FOREIGN KEY (mowerId) REFERENCES mowers (id) ON DELETE SET NULL
)
`);
// Add new columns to existing zones table if they don't exist
try {
await db.execute(`ALTER TABLE zones ADD COLUMN nextMowDate TEXT`);
} catch (error) {
// Column already exists, ignore error
}
try {
await db.execute(`ALTER TABLE zones ADD COLUMN scheduleType TEXT DEFAULT 'interval'`);
} catch (error) {
// Column already exists, ignore error
}
// Add area column to existing table if it doesn't exist // Add area column to existing table if it doesn't exist
try { try {
await db.execute(`ALTER TABLE zones ADD COLUMN area REAL DEFAULT 0`); await db.execute(`ALTER TABLE zones ADD COLUMN area REAL DEFAULT 0`);
@ -80,6 +125,36 @@ try {
// Column already exists, ignore error // Column already exists, ignore error
} }
// Add mowerId column to existing mowing_history table if it doesn't exist
try {
await db.execute(`ALTER TABLE mowing_history ADD COLUMN mowerId INTEGER REFERENCES mowers (id) ON DELETE SET NULL`);
} catch (error) {
// Column already exists, ignore error
}
// Add sessionId column to existing mowing_history table if it doesn't exist
try {
await db.execute(`ALTER TABLE mowing_history ADD COLUMN sessionId TEXT`);
} catch (error) {
// Column already exists, ignore error
}
// Insert default mowers if table is empty
const mowerCountResult = await db.execute('SELECT COUNT(*) as count FROM mowers');
const mowerCount = mowerCountResult.rows[0].count;
if (mowerCount === 0) {
await db.execute({
sql: 'INSERT INTO mowers (name, type, brand, model) VALUES (?, ?, ?, ?)',
args: ['Battery Mower', 'Battery', 'Generic', 'Battery Model']
});
await db.execute({
sql: 'INSERT INTO mowers (name, type, brand, model) VALUES (?, ?, ?, ?)',
args: ['Electric Mower', 'Electric', 'Generic', 'Electric Model']
});
}
// Insert sample data if table is empty // Insert sample data if table is empty
const countResult = await db.execute('SELECT COUNT(*) as count FROM zones'); const countResult = await db.execute('SELECT COUNT(*) as count FROM zones');
const count = countResult.rows[0].count; const count = countResult.rows[0].count;
@ -89,30 +164,84 @@ if (count === 0) {
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
const twoWeeksAgo = new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000); const twoWeeksAgo = new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000);
await db.execute({ // Get mower IDs for sample data
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)', const mowersResult = await db.execute('SELECT id FROM mowers LIMIT 2');
args: ['Front Yard', weekAgo.toISOString(), 7, 150.5] const batteryMowerId = mowersResult.rows[0]?.id || 1;
const electricMowerId = mowersResult.rows[1]?.id || 2;
// Insert zones with different schedule types
const frontYardResult = await db.execute({
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, scheduleType, area) VALUES (?, ?, ?, ?, ?)',
args: ['Front Yard', weekAgo.toISOString(), 7, 'interval', 150.5]
}); });
await db.execute({ const backYardResult = await db.execute({
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)', sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, scheduleType, area) VALUES (?, ?, ?, ?, ?)',
args: ['Back Yard', twoWeeksAgo.toISOString(), 10, 280.0] args: ['Back Yard', twoWeeksAgo.toISOString(), 10, 'interval', 280.0]
}); });
await db.execute({ const nextWeek = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000);
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)', const sideGardenResult = await db.execute({
args: ['Side Garden', today.toISOString(), 14, 75.25] sql: 'INSERT INTO zones (name, lastMowedDate, nextMowDate, scheduleType, area) VALUES (?, ?, ?, ?, ?)',
args: ['Side Garden', today.toISOString(), nextWeek.toISOString(), 'specific', 75.25]
}); });
// Insert sample mowing history with mower assignments
const sampleHistory = [
{ zoneId: frontYardResult.lastInsertRowid, mowerId: batteryMowerId, date: today, duration: 45, weather: 'Sunny', notes: 'Perfect mowing conditions today' },
{ zoneId: frontYardResult.lastInsertRowid, mowerId: electricMowerId, date: weekAgo, duration: 40, weather: 'Cloudy', notes: 'Grass was a bit wet from morning dew' },
{ zoneId: backYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000), duration: 60, weather: 'Partly cloudy', notes: 'Trimmed edges and cleaned up leaves' },
{ zoneId: sideGardenResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 3 * 24 * 60 * 60 * 1000), duration: 25, weather: 'Sunny', notes: 'Quick touch-up, looks great' },
{ zoneId: frontYardResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000), duration: 50, weather: 'Overcast', notes: 'First mow of the season' },
{ zoneId: backYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 16 * 24 * 60 * 60 * 1000), duration: 65, weather: 'Sunny', notes: 'Had to go slow due to thick growth' },
{ zoneId: sideGardenResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 17 * 24 * 60 * 60 * 1000), duration: 30, weather: 'Windy', notes: 'Challenging conditions but got it done' },
{ zoneId: frontYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 21 * 24 * 60 * 60 * 1000), duration: 42, weather: 'Cool', notes: 'Nice cool morning for mowing' },
{ zoneId: backYardResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000), duration: 55, weather: 'Humid', notes: 'Very humid day, took frequent breaks' },
{ zoneId: sideGardenResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 31 * 24 * 60 * 60 * 1000), duration: 28, weather: 'Sunny', notes: 'Beautiful day for yard work' },
{ zoneId: frontYardResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 35 * 24 * 60 * 60 * 1000), duration: 38, weather: 'Partly cloudy', notes: 'Standard weekly maintenance' },
{ zoneId: backYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 44 * 24 * 60 * 60 * 1000), duration: 70, weather: 'Hot', notes: 'Very hot day, started early morning' },
];
for (const history of sampleHistory) {
await db.execute({
sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, duration, weather, notes) VALUES (?, ?, ?, ?, ?, ?)',
args: [history.zoneId, history.mowerId, history.date.toISOString(), history.duration, history.weather, history.notes]
});
}
} }
// Helper function to calculate zone status // Helper function to calculate zone status
function calculateZoneStatus(zone) { function calculateZoneStatus(zone) {
const today = new Date(); const today = new Date();
// For new zones that haven't been mowed yet
if (!zone.lastMowedDate) {
return {
...zone,
daysSinceLastMow: null,
daysUntilNext: null,
status: 'new',
isOverdue: false,
isDueToday: false,
isNew: true
};
}
const lastMowed = new Date(zone.lastMowedDate); const lastMowed = new Date(zone.lastMowedDate);
const daysSinceLastMow = Math.floor((today - lastMowed) / (1000 * 60 * 60 * 24)); const daysSinceLastMow = Math.floor((today - lastMowed) / (1000 * 60 * 60 * 24));
const daysUntilNext = zone.intervalDays - daysSinceLastMow;
let daysUntilNext;
let status = 'ok'; let status = 'ok';
if (zone.scheduleType === 'specific' && zone.nextMowDate) {
// Specific date scheduling
const nextMowDate = new Date(zone.nextMowDate);
daysUntilNext = Math.floor((nextMowDate - today) / (1000 * 60 * 60 * 24));
} else {
// Interval-based scheduling
daysUntilNext = zone.intervalDays - daysSinceLastMow;
}
if (daysUntilNext < 0) { if (daysUntilNext < 0) {
status = 'overdue'; status = 'overdue';
} else if (daysUntilNext <= 0) { } else if (daysUntilNext <= 0) {
@ -127,10 +256,73 @@ function calculateZoneStatus(zone) {
daysUntilNext, daysUntilNext,
status, status,
isOverdue: daysUntilNext < 0, isOverdue: daysUntilNext < 0,
isDueToday: daysUntilNext <= 0 && daysUntilNext >= -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 // Routes
app.get('/api/zones', async (req, res) => { app.get('/api/zones', async (req, res) => {
try { try {
@ -141,6 +333,8 @@ app.get('/api/zones', async (req, res) => {
imagePath: row.imagePath, imagePath: row.imagePath,
lastMowedDate: row.lastMowedDate, lastMowedDate: row.lastMowedDate,
intervalDays: row.intervalDays, intervalDays: row.intervalDays,
nextMowDate: row.nextMowDate,
scheduleType: row.scheduleType || 'interval',
area: row.area || 0, area: row.area || 0,
createdAt: row.createdAt createdAt: row.createdAt
})); }));
@ -168,6 +362,8 @@ app.get('/api/zones/:id', async (req, res) => {
imagePath: result.rows[0].imagePath, imagePath: result.rows[0].imagePath,
lastMowedDate: result.rows[0].lastMowedDate, lastMowedDate: result.rows[0].lastMowedDate,
intervalDays: result.rows[0].intervalDays, intervalDays: result.rows[0].intervalDays,
nextMowDate: result.rows[0].nextMowDate,
scheduleType: result.rows[0].scheduleType || 'interval',
area: result.rows[0].area || 0, area: result.rows[0].area || 0,
createdAt: result.rows[0].createdAt 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) => { app.post('/api/zones', upload.single('image'), async (req, res) => {
try { 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 imagePath = req.file ? `/uploads/${req.file.filename}` : null;
const lastMowedDate = new Date().toISOString();
const result = await db.execute({ // For new zones, don't set lastMowedDate (they haven't been mowed yet)
sql: 'INSERT INTO zones (name, imagePath, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?, ?)', let sql, args;
args: [name, imagePath, lastMowedDate, parseInt(intervalDays), parseFloat(area) || 0]
}); 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({ const newZoneResult = await db.execute({
sql: 'SELECT * FROM zones WHERE id = ?', 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, imagePath: newZoneResult.rows[0].imagePath,
lastMowedDate: newZoneResult.rows[0].lastMowedDate, lastMowedDate: newZoneResult.rows[0].lastMowedDate,
intervalDays: newZoneResult.rows[0].intervalDays, intervalDays: newZoneResult.rows[0].intervalDays,
nextMowDate: newZoneResult.rows[0].nextMowDate,
scheduleType: newZoneResult.rows[0].scheduleType || 'interval',
area: newZoneResult.rows[0].area || 0, area: newZoneResult.rows[0].area || 0,
createdAt: newZoneResult.rows[0].createdAt 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) => { app.put('/api/zones/:id', upload.single('image'), async (req, res) => {
try { try {
const { name, intervalDays, lastMowedDate, area } = req.body; const { name, intervalDays, lastMowedDate, nextMowDate, scheduleType, area } = req.body;
const existingResult = await db.execute({ const existingResult = await db.execute({
sql: 'SELECT * FROM zones WHERE id = ?', 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 existingZone = existingResult.rows[0];
const imagePath = req.file ? `/uploads/${req.file.filename}` : existingZone.imagePath; const imagePath = req.file ? `/uploads/${req.file.filename}` : existingZone.imagePath;
await db.execute({ let sql, args;
sql: 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, intervalDays = ?, area = ? WHERE id = ?',
args: [name, imagePath, lastMowedDate, parseInt(intervalDays), parseFloat(area) || 0, req.params.id] 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 // Delete old image if new one was provided
if (req.file && existingZone.imagePath) { 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, imagePath: updatedResult.rows[0].imagePath,
lastMowedDate: updatedResult.rows[0].lastMowedDate, lastMowedDate: updatedResult.rows[0].lastMowedDate,
intervalDays: updatedResult.rows[0].intervalDays, intervalDays: updatedResult.rows[0].intervalDays,
nextMowDate: updatedResult.rows[0].nextMowDate,
scheduleType: updatedResult.rows[0].scheduleType || 'interval',
area: updatedResult.rows[0].area || 0, area: updatedResult.rows[0].area || 0,
createdAt: updatedResult.rows[0].createdAt 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) => { app.post('/api/zones/:id/mow', async (req, res) => {
try { try {
const { notes, duration, weather, mowerId } = req.body;
const today = new Date().toISOString(); 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({ await db.execute({
sql: 'UPDATE zones SET lastMowedDate = ? WHERE id = ?', sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather) VALUES (?, ?, ?, ?, ?, ?)',
args: [today, req.params.id] args: [req.params.id, mowerId || null, today, notes || null, duration || null, weather || null]
}); });
const updatedResult = await db.execute({ const updatedResult = await db.execute({
@ -315,6 +748,8 @@ app.post('/api/zones/:id/mow', async (req, res) => {
imagePath: updatedResult.rows[0].imagePath, imagePath: updatedResult.rows[0].imagePath,
lastMowedDate: updatedResult.rows[0].lastMowedDate, lastMowedDate: updatedResult.rows[0].lastMowedDate,
intervalDays: updatedResult.rows[0].intervalDays, intervalDays: updatedResult.rows[0].intervalDays,
nextMowDate: updatedResult.rows[0].nextMowDate,
scheduleType: updatedResult.rows[0].scheduleType || 'interval',
area: updatedResult.rows[0].area || 0, area: updatedResult.rows[0].area || 0,
createdAt: updatedResult.rows[0].createdAt 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 // Serve React app in production
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
app.get('*', (req, res) => { app.get('*', (req, res) => {

View File

@ -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<BulkMowingModalProps> = ({
zones,
onSubmit,
onCancel,
loading = false
}) => {
const [formData, setFormData] = useState<BulkMowingFormData>({
selectedZoneIds: [],
notes: '',
totalDuration: undefined,
weather: '',
mowerId: undefined,
});
const [mowers, setMowers] = useState<Mower[]>([]);
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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b">
<div>
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<Scissors className="h-6 w-6 text-green-600" />
Сеанс массового скашивания
</h2>
<p className="text-sm text-gray-600 mt-1">
Выберите несколько зон, скошенных за один сеанс
</p>
</div>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600 transition-colors duration-200"
>
<X className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Zone Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Выберите зоны для скашивания ({formData.selectedZoneIds.length} выбрано)
</label>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 max-h-60 overflow-y-auto border rounded-lg p-3">
{zones.map(zone => (
<div
key={zone.id}
className={`relative border rounded-lg p-3 cursor-pointer transition-all duration-200 ${
formData.selectedZoneIds.includes(zone.id)
? 'border-green-500 bg-green-50 ring-2 ring-green-200'
: `${getZoneStatusColor(zone)} hover:border-gray-300`
}`}
onClick={() => handleZoneToggle(zone.id)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-gray-900">{zone.name}</h4>
<p className="text-xs text-gray-600 mt-1">
{getZoneStatusText(zone)}
</p>
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500">
<Square className="h-3 w-3" />
<span>{zone.area.toLocaleString()} м2</span>
</div>
</div>
{formData.selectedZoneIds.includes(zone.id) && (
<CheckCircle className="h-5 w-5 text-green-600 flex-shrink-0" />
)}
</div>
</div>
))}
</div>
{formData.selectedZoneIds.length > 0 && (
<div className="mt-3 p-3 bg-blue-50 rounded-md">
<p className="text-sm text-blue-800">
<strong>Selected:</strong> {selectedZones.map(z => z.name).join(', ')}
<span className="ml-2">
(Общая площадь: {totalSelectedArea.toLocaleString()} м2)
</span>
</p>
</div>
)}
</div>
{/* Session Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Mower Selection */}
<div>
<label htmlFor="mowerId" className="block text-sm font-medium text-gray-700 mb-2">
<Scissors className="h-4 w-4 inline mr-2" />
Использованная газонокосилка
</label>
{loadingMowers ? (
<div className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50">
<span className="text-gray-500">Loading mowers...</span>
</div>
) : (
<select
id="mowerId"
value={formData.mowerId || ''}
onChange={(e) => setFormData(prev => ({
...prev,
mowerId: 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"
required
>
<option value="">Выбор газонокосилки</option>
{mowers.map(mower => (
<option key={mower.id} value={mower.id}>
{getMowerIcon(mower.type)} {mower.name} ({mower.type})
</option>
))}
</select>
)}
</div>
{/* Total Duration */}
<div>
<label htmlFor="totalDuration" className="block text-sm font-medium text-gray-700 mb-2">
<Timer className="h-4 w-4 inline mr-2" />
Общая продолжительность сеанса (минуты)
</label>
<input
type="number"
id="totalDuration"
value={formData.totalDuration || ''}
onChange={(e) => 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"
/>
<p className="text-xs text-gray-500 mt-1">
Время будет распределено пропорционально площади зоны
</p>
</div>
{/* Weather */}
<div>
<label htmlFor="weather" className="block text-sm font-medium text-gray-700 mb-2">
<Cloud className="h-4 w-4 inline mr-2" />
Погодные условия
</label>
<select
id="weather"
value={formData.weather || ''}
onChange={(e) => setFormData(prev => ({ ...prev, weather: e.target.value }))}
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"
>
<option value="">Выбор погоды (не обязательно)</option>
{weatherOptions.map(weather => (
<option key={weather} value={weather}>{weather}</option>
))}
</select>
</div>
{/* Notes */}
<div>
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-2">
<FileText className="h-4 w-4 inline mr-2" />
Заметки
</label>
<textarea
id="notes"
value={formData.notes || ''}
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
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"
rows={3}
placeholder="Заметки о сеансе стрижки газона..."
/>
</div>
</div>
{/* Time Distribution Preview */}
{timeDistribution.length > 0 && formData.totalDuration && (
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-900 mb-3 flex items-center gap-2">
<Timer className="h-4 w-4" />
Предварительный просмотр распределения времени
</h4>
<div className="space-y-2">
{timeDistribution.map(({ zone, proportion, allocatedTime }) => (
<div key={zone.id} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="font-medium">{zone.name}</span>
<span className="text-gray-500">
({zone.area.toLocaleString()} м2)
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-20 bg-gray-200 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full"
style={{ width: `${proportion}%` }}
></div>
</div>
<span className="font-medium text-gray-900 w-12 text-right">
{allocatedTime}м
</span>
</div>
</div>
))}
</div>
<div className="mt-3 pt-3 border-t border-gray-200 flex justify-between text-sm font-medium">
<span>Всего:</span>
<span>{formData.totalDuration}м</span>
</div>
</div>
)}
{/* Form Actions */}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onCancel}
className="flex-1 py-2 px-4 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors duration-200"
>
Отмена
</button>
<button
type="submit"
disabled={loading || formData.selectedZoneIds.length === 0}
className="flex-1 py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{loading ? 'Запись сессии...' : `Запись покоса (${formData.selectedZoneIds.length} зон)`}
</button>
</div>
</form>
</div>
</div>
);
};
export default BulkMowingModal;

View File

@ -1,13 +1,16 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Plus, Filter, Calendar, Scissors, AlertTriangle, Square, CheckCircle, Clock, Map } from './Icons'; import { Plus, Filter, Calendar, Scissors, AlertTriangle, Square, CheckCircle, Clock, Map, History } from './Icons';
import { Zone } from '../types/zone'; import { Zone, MowingFormData, BulkMowingFormData } from '../types/zone';
import { api } from '../services/api'; import { api } from '../services/api';
import ZoneCard from './ZoneCard'; import ZoneCard from './ZoneCard';
import ZoneForm from './ZoneForm'; import ZoneForm from './ZoneForm';
import SitePlan from './SitePlan'; import SitePlan from './SitePlan';
import HistoryView from './HistoryView';
import MowingModal from './MowingModal';
import BulkMowingModal from "./BulkMowingModal";
type FilterType = 'all' | 'due' | 'overdue'; type FilterType = 'all' | 'due' | 'overdue' | 'new';
type ViewType = 'dashboard' | 'sitePlan'; type ViewType = 'dashboard' | 'sitePlan' | 'history';
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const [zones, setZones] = useState<Zone[]>([]); const [zones, setZones] = useState<Zone[]>([]);
@ -17,7 +20,11 @@ const Dashboard: React.FC = () => {
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingZone, setEditingZone] = useState<Zone | null>(null); const [editingZone, setEditingZone] = useState<Zone | null>(null);
const [selectedZoneId, setSelectedZoneId] = useState<number | undefined>(); const [selectedZoneId, setSelectedZoneId] = useState<number | undefined>();
const [showMowingModal, setShowMowingModal] = useState(false);
const [showBulkMowingModal, setShowBulkMowingModal] = useState(false);
const [mowingZone, setMowingZone] = useState<Zone | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [mowingLoading, setMowingLoading] = useState(false);
useEffect(() => { useEffect(() => {
loadZones(); loadZones();
@ -47,18 +54,46 @@ const Dashboard: React.FC = () => {
case 'overdue': case 'overdue':
filtered = zones.filter(zone => zone.isOverdue); filtered = zones.filter(zone => zone.isOverdue);
break; break;
case 'new':
filtered = zones.filter(zone => zone.isNew);
break;
default: default:
filtered = zones; filtered = zones;
} }
setFilteredZones(filtered); setFilteredZones(filtered);
}; };
const handleMarkAsMowed = async (id: number) => { const handleMarkAsMowed = (zone: Zone) => {
setMowingZone(zone);
setShowMowingModal(true);
};
const handleMowingSubmit = async (data: MowingFormData) => {
if (!mowingZone) return;
setMowingLoading(true);
try { try {
await api.markAsMowed(id); await api.markAsMowed(mowingZone.id, data);
setShowMowingModal(false);
setMowingZone(null);
loadZones(); loadZones();
} catch (error) { } catch (error) {
console.error('Failed to mark as mowed:', error); console.error('Failed to mark as mowed:', error);
} finally {
setMowingLoading(false);
}
};
const handleBulkMowingSubmit = async (data: BulkMowingFormData) => {
setMowingLoading(true);
try {
await api.bulkMarkAsMowed(data);
setShowBulkMowingModal(false);
loadZones();
} catch (error) {
console.error('Failed to record bulk mowing session:', error);
} finally {
setMowingLoading(false);
} }
}; };
@ -97,6 +132,7 @@ const Dashboard: React.FC = () => {
// Calculate area statistics // Calculate area statistics
const overdueCount = zones.filter(zone => zone.isOverdue).length; const overdueCount = zones.filter(zone => zone.isOverdue).length;
const dueCount = zones.filter(zone => zone.isDueToday).length; const dueCount = zones.filter(zone => zone.isDueToday).length;
const newCount = zones.filter(zone => zone.isNew).length;
const okCount = zones.filter(zone => zone.status === 'ok').length; const okCount = zones.filter(zone => zone.status === 'ok').length;
const totalArea = zones.reduce((sum, zone) => sum + zone.area, 0); const totalArea = zones.reduce((sum, zone) => sum + zone.area, 0);
@ -105,12 +141,13 @@ const Dashboard: React.FC = () => {
.filter(zone => zone.status === 'ok') .filter(zone => zone.status === 'ok')
.reduce((sum, zone) => sum + zone.area, 0); .reduce((sum, zone) => sum + zone.area, 0);
const remainingArea = zones
.filter(zone => zone.status === 'due' || zone.status === 'overdue')
.reduce((sum, zone) => sum + zone.area, 0);
const mowedPercentage = totalArea > 0 ? (mowedArea / totalArea) * 100 : 0; const mowedPercentage = totalArea > 0 ? (mowedArea / totalArea) * 100 : 0;
// Check if there are zones that need mowing for bulk action
const zonesNeedingMowing = zones.filter(zone =>
zone.status === 'due' || zone.status === 'overdue' || zone.status === 'new'
);
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="min-h-screen bg-gray-50 flex items-center justify-center">
@ -157,6 +194,17 @@ const Dashboard: React.FC = () => {
<Map className="h-4 w-4 inline mr-2" /> <Map className="h-4 w-4 inline mr-2" />
План участка План участка
</button> </button>
<button
onClick={() => setView('history')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
view === 'history'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<History className="h-4 w-4 inline mr-2" />
История
</button>
</div> </div>
<button <button
@ -170,7 +218,9 @@ const Dashboard: React.FC = () => {
</div> </div>
</div> </div>
{view === 'sitePlan' ? ( {view === 'history' ? (
<HistoryView zones={zones} />
) : view === 'sitePlan' ? (
<SitePlan <SitePlan
zones={zones} zones={zones}
onZoneSelect={handleZoneSelect} onZoneSelect={handleZoneSelect}
@ -183,7 +233,7 @@ const Dashboard: React.FC = () => {
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-green-500"> <div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-green-500">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-gray-600">Количество зон</p> <p className="text-sm font-medium text-gray-600">Кол-во зон</p>
<p className="text-2xl font-bold text-gray-900">{zones.length}</p> <p className="text-2xl font-bold text-gray-900">{zones.length}</p>
</div> </div>
<Calendar className="h-8 w-8 text-green-500" /> <Calendar className="h-8 w-8 text-green-500" />
@ -193,7 +243,7 @@ const Dashboard: React.FC = () => {
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-purple-500"> <div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-purple-500">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-gray-600">Общая площадь</p> <p className="text-sm font-medium text-gray-600">Площадь, вся</p>
<p className="text-2xl font-bold text-gray-900">{totalArea.toLocaleString()}</p> <p className="text-2xl font-bold text-gray-900">{totalArea.toLocaleString()}</p>
<p className="text-xs text-gray-500">м2</p> <p className="text-xs text-gray-500">м2</p>
</div> </div>
@ -204,20 +254,33 @@ const Dashboard: React.FC = () => {
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-emerald-500"> <div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-emerald-500">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-gray-600">Скошенная площадь</p> <p className="text-sm font-medium text-gray-600">Актуально</p>
<p className="text-2xl font-bold text-gray-900">{mowedArea.toLocaleString()}</p> <p className="text-2xl font-bold text-gray-900">{okCount}</p>
<p className="text-xs text-gray-500">м2 ({mowedPercentage.toFixed(1)}%)</p> <p className="text-xs text-gray-500">обслужено зон</p>
</div> </div>
<CheckCircle className="h-8 w-8 text-emerald-500" /> <CheckCircle className="h-8 w-8 text-emerald-500" />
</div> </div>
</div> </div>
{/*
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-blue-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Новые зоны</p>
<p className="text-2xl font-bold text-gray-900">{newCount}</p>
<p className="text-xs text-gray-500">еще не косились</p>
</div>
<Calendar className="h-8 w-8 text-blue-500" />
</div>
</div>
*/}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-amber-500"> <div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-amber-500">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-gray-600">Нужно скосить</p> <p className="text-sm font-medium text-gray-600">Внимание!</p>
<p className="text-2xl font-bold text-gray-900">{remainingArea.toLocaleString()}</p> <p className="text-2xl font-bold text-gray-900">{dueCount + overdueCount}</p>
<p className="text-xs text-gray-500">м2 ({(100 - mowedPercentage).toFixed(1)}%)</p> <p className="text-xs text-gray-500">зон для покоса</p>
</div> </div>
<Clock className="h-8 w-8 text-amber-500" /> <Clock className="h-8 w-8 text-amber-500" />
</div> </div>
@ -262,16 +325,41 @@ const Dashboard: React.FC = () => {
<div className="flex justify-between text-sm text-gray-600"> <div className="flex justify-between text-sm text-gray-600">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-emerald-500" /> <CheckCircle className="h-4 w-4 text-emerald-500" />
{mowedPercentage.toFixed(1)}% Завершено {mowedPercentage.toFixed(1)}% Завершено ({okCount} зон)
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock className="h-4 w-4 text-amber-500" /> <Clock className="h-4 w-4 text-amber-500" />
{(100 - mowedPercentage).toFixed(1)}% Осталось {(100 - mowedPercentage).toFixed(1)}% Осталось ({dueCount + overdueCount} зон)
</span> </span>
</div> </div>
</div> </div>
)} )}
{/* Bulk Mowing Notice */}
{zonesNeedingMowing.length > 1 && (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Scissors className="h-5 w-5 text-orange-600" />
<div>
<h4 className="text-sm font-medium text-orange-800">
Необходимо скосить несколько зон
</h4>
<p className="text-sm text-orange-700">
Используйте массовое скашивание для записи нескольких зон, скошенных за один сеанс, с пропорциональным распределением времени
</p>
</div>
</div>
<button
onClick={() => setShowBulkMowingModal(true)}
className="bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
>
Массовый покос ({zonesNeedingMowing.length})
</button>
</div>
</div>
)}
{/* Filter Buttons */} {/* Filter Buttons */}
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -280,6 +368,7 @@ const Dashboard: React.FC = () => {
<div className="flex gap-2"> <div className="flex gap-2">
{[ {[
{ key: 'all' as FilterType, label: 'Все зоны', count: zones.length }, { key: 'all' as FilterType, label: 'Все зоны', count: zones.length },
{ key: 'new' as FilterType, label: 'Новые зоны', count: newCount },
{ key: 'due' as FilterType, label: 'Срок - сегодня', count: dueCount }, { key: 'due' as FilterType, label: 'Срок - сегодня', count: dueCount },
{ key: 'overdue' as FilterType, label: 'Срок прошел', count: overdueCount }, { key: 'overdue' as FilterType, label: 'Срок прошел', count: overdueCount },
].map(({ key, label, count }) => ( ].map(({ key, label, count }) => (
@ -303,9 +392,9 @@ const Dashboard: React.FC = () => {
{filteredZones.length === 0 ? ( {filteredZones.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<Scissors className="mx-auto h-12 w-12 text-gray-400" /> <Scissors className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No zones found</h3> <h3 className="mt-2 text-sm font-medium text-gray-900">Не найдено ни одной зоны</h3>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
{filter === 'all' ? 'Get started by adding your first lawn zone.' : `No zones match the "${filter}" filter.`} {filter === 'all' ? 'Get started by adding your first lawn zone.' : `Нет подходящих зон для "${filter}" фильтра.`}
</p> </p>
</div> </div>
) : ( ) : (
@ -347,6 +436,30 @@ const Dashboard: React.FC = () => {
}} }}
/> />
)} )}
{/* Single Zone Mowing Modal */}
{showMowingModal && mowingZone && (
<MowingModal
zoneName={mowingZone.name}
onSubmit={handleMowingSubmit}
onCancel={() => {
setShowMowingModal(false);
setMowingZone(null);
}}
loading={mowingLoading}
/>
)}
{/* Bulk Mowing Modal */}
{showBulkMowingModal && (
<BulkMowingModal
zones={zones}
onSubmit={handleBulkMowingSubmit}
onCancel={() => setShowBulkMowingModal(false)}
loading={mowingLoading}
/>
)}
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,562 @@
import React, { useState, useEffect } from 'react';
import { History, BarChart, Calendar, Timer, Cloud, FileText, Square, ChevronDown, ChevronUp, Scissors, Users } from './Icons';
import { MowingHistory, MowingHistoryResponse, MowingStats, Zone } from '../types/zone';
import { api } from '../services/api';
import Pagination from './Pagination';
interface HistoryViewProps {
zones: Zone[];
}
interface SessionGroup {
sessionId: string;
entries: MowingHistory[];
totalDuration: number;
totalArea: number;
mowedDate: string;
weather?: string;
notes?: string;
mowerName?: string;
mowerType?: string;
}
const HistoryView: React.FC<HistoryViewProps> = ({ zones }) => {
const [historyResponse, setHistoryResponse] = useState<MowingHistoryResponse | null>(null);
const [stats, setStats] = useState<MowingStats | null>(null);
const [selectedZone, setSelectedZone] = useState<number | undefined>();
const [period, setPeriod] = useState(30);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [loading, setLoading] = useState(true);
const [expandedEntry, setExpandedEntry] = useState<number | null>(null);
const [expandedSession, setExpandedSession] = useState<string | null>(null);
useEffect(() => {
loadData();
}, [selectedZone, period, currentPage, pageSize]);
const loadData = async () => {
setLoading(true);
try {
const offset = (currentPage - 1) * pageSize;
const [historyData, statsData] = await Promise.all([
api.getMowingHistory(selectedZone, pageSize, offset),
api.getMowingStats(period)
]);
setHistoryResponse(historyData);
setStats(statsData);
} catch (error) {
console.error('Failed to load history data:', error);
} finally {
setLoading(false);
}
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handleZoneChange = (zoneId: number | undefined) => {
setSelectedZone(zoneId);
setCurrentPage(1); // Reset to first page when changing filters
};
const handlePeriodChange = (newPeriod: number) => {
setPeriod(newPeriod);
setCurrentPage(1); // Reset to first page when changing filters
};
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setCurrentPage(1); // Reset to first page when changing page size
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const formatTime = (dateString: string) => {
return new Date(dateString).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
});
};
const formatDuration = (minutes: number) => {
if (minutes < 60) return `${minutes}м`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}ч ${mins}м` : `${hours}ч`;
};
const getZoneName = (zoneId: number) => {
const zone = zones.find(z => z.id === zoneId);
return zone?.name || 'Неизвестная зона';
};
const getMowerIcon = (type?: string) => {
if (!type) return '🚜';
switch (type.toLowerCase()) {
case 'battery':
return '🔋';
case 'electric':
return '⚡';
case 'gas':
case 'petrol':
return '⛽';
default:
return '🚜';
}
};
// Group history entries by session
const groupBySession = (entries: MowingHistory[]): (SessionGroup | MowingHistory)[] => {
const sessionMap = new Map<string, MowingHistory[]>();
const singleEntries: MowingHistory[] = [];
entries.forEach(entry => {
if (entry.sessionId) {
if (!sessionMap.has(entry.sessionId)) {
sessionMap.set(entry.sessionId, []);
}
sessionMap.get(entry.sessionId)!.push(entry);
} else {
singleEntries.push(entry);
}
});
const result: (SessionGroup | MowingHistory)[] = [];
// Add session groups
sessionMap.forEach((sessionEntries, sessionId) => {
if (sessionEntries.length > 1) {
// This is a bulk session
const totalDuration = sessionEntries.reduce((sum, entry) => sum + (entry.duration || 0), 0);
const totalArea = sessionEntries.reduce((sum, entry) => sum + entry.zoneArea, 0);
const firstEntry = sessionEntries[0];
result.push({
sessionId,
entries: sessionEntries.sort((a, b) => a.zoneName.localeCompare(b.zoneName)),
totalDuration,
totalArea,
mowedDate: firstEntry.mowedDate,
weather: firstEntry.weather,
notes: firstEntry.notes,
mowerName: firstEntry.mowerName,
mowerType: firstEntry.mowerType,
});
} else {
// Single entry that happens to have a session ID
result.push(sessionEntries[0]);
}
});
// Add single entries
singleEntries.forEach(entry => result.push(entry));
// Sort by date (most recent first)
return result.sort((a, b) => {
const dateA = a.mowedDate;
const dateB = b.mowedDate;
return new Date(dateB).getTime() - new Date(dateA).getTime();
});
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
);
}
const groupedHistory = historyResponse ? groupBySession(historyResponse.data) : [];
return (
<div className="space-y-6">
{/* Header and Filters */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<History className="h-6 w-6 text-green-600" />
История покосов
</h2>
<p className="text-gray-600 mt-1">Следите за своими мероприятиями по уходу за газоном и прогрессом в их выполнении</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
{/* Zone Filter */}
<select
value={selectedZone || ''}
onChange={(e) => handleZoneChange(e.target.value ? parseInt(e.target.value) : undefined)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option value="">Все Зоны</option>
{zones.map(zone => (
<option key={zone.id} value={zone.id}>{zone.name}</option>
))}
</select>
{/* Period Filter */}
<select
value={period}
onChange={(e) => handlePeriodChange(parseInt(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option value={7}>Последние 7 дней</option>
<option value={30}>Последние 30 дней</option>
<option value={90}>Последние 3 месяца</option>
<option value={365}>Последний год</option>
</select>
{/* Page Size Filter */}
<select
value={pageSize}
onChange={(e) => handlePageSizeChange(parseInt(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option value={5}>5 на странице</option>
<option value={10}>10 на странице</option>
<option value={20}>20 на странице</option>
<option value={50}>50 на странице</option>
</select>
</div>
</div>
{/* Statistics Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-blue-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Всего сессий</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalSessions}</p>
</div>
<BarChart className="h-8 w-8 text-blue-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-green-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Всего времени</p>
<p className="text-2xl font-bold text-gray-900">
{formatDuration(stats.totalMinutes)}
</p>
</div>
<Timer className="h-8 w-8 text-green-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-purple-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Всего площадь</p>
<p className="text-2xl font-bold text-gray-900">
{stats.totalArea.toLocaleString()}
</p>
<p className="text-xs text-gray-500">м2</p>
</div>
<Square className="h-8 w-8 text-purple-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-orange-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Наибольшая активность</p>
<p className="text-lg font-bold text-gray-900">
{stats.mostActiveZone?.name || 'НЕТ'}
</p>
{stats.mostActiveZone && (
<p className="text-xs text-gray-500">
{stats.mostActiveZone.sessions} сессии
</p>
)}
</div>
<Calendar className="h-8 w-8 text-orange-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-indigo-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Часто используется</p>
<p className="text-lg font-bold text-gray-900">
{stats.mostUsedMower?.name || 'НЕТ'}
</p>
{stats.mostUsedMower && (
<p className="text-xs text-gray-500">
{getMowerIcon(stats.mostUsedMower.type)} {stats.mostUsedMower.sessions} сессии
</p>
)}
</div>
<Scissors className="h-8 w-8 text-indigo-500" />
</div>
</div>
</div>
)}
{/* History List */}
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<div className="px-6 py-4 border-b bg-gray-50">
<h3 className="text-lg font-semibold text-gray-900">Недавняя активность</h3>
<p className="text-sm text-gray-600 mt-1">
{selectedZone ? `История для ${getZoneName(selectedZone)}` : 'Все зоны'}
</p>
</div>
{!historyResponse || historyResponse.data.length === 0 ? (
<div className="text-center py-12">
<History className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">Пока нет истории</h3>
<p className="mt-1 text-sm text-gray-500">
{selectedZone
? 'В этой зоне еще нет выходов на скашивание.'
: 'Начните скашивать зоны, чтобы просмотреть свою историю здесь.'
}
</p>
</div>
) : (
<>
<div className="divide-y divide-gray-200">
{groupedHistory.map((item, index) => {
// Check if this is a session group or single entry
if ('entries' in item && item.entries.length > 1) {
// This is a bulk session
const session = item as SessionGroup;
const isExpanded = expandedSession === session.sessionId;
return (
<div key={`session-${session.sessionId}`} className="bg-gradient-to-r from-blue-50 to-indigo-50">
{/* Session Header */}
<div className="p-6 border-l-4 border-blue-500">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-blue-600" />
<h4 className="text-lg font-medium text-gray-900">
Сессия массового скашивания
</h4>
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
{session.entries.length} зоны
</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600 mb-3">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>{formatDate(session.mowedDate)}</span>
<span className="text-gray-400">at {formatTime(session.mowedDate)}</span>
</div>
{session.mowerName && (
<div className="flex items-center gap-1">
<span className="text-base">{getMowerIcon(session.mowerType)}</span>
<span>{session.mowerName}</span>
</div>
)}
{session.totalDuration > 0 && (
<div className="flex items-center gap-1">
<Timer className="h-4 w-4" />
<span>{formatDuration(session.totalDuration)} всего</span>
</div>
)}
<div className="flex items-center gap-1">
<Square className="h-4 w-4" />
<span>{session.totalArea.toLocaleString()} м2 всего</span>
</div>
{session.weather && (
<div className="flex items-center gap-1">
<Cloud className="h-4 w-4" />
<span>{session.weather}</span>
</div>
)}
</div>
{/* Zone Summary */}
<div className="mb-3">
<p className="text-sm text-gray-700">
<strong>Скошенные зоны:</strong> {session.entries.map(e => e.zoneName).join(', ')}
</p>
</div>
{session.notes && (
<div className="mt-3">
<button
onClick={() => setExpandedEntry(
expandedEntry === -index ? null : -index
)}
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800 transition-colors duration-200"
>
<FileText className="h-4 w-4" />
<span>Session Notes</span>
{expandedEntry === -index ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{expandedEntry === -index && (
<div className="mt-2 p-3 bg-white rounded-md border">
<p className="text-sm text-gray-700">{session.notes}</p>
</div>
)}
</div>
)}
</div>
<button
onClick={() => setExpandedSession(
isExpanded ? null : session.sessionId
)}
className="ml-4 p-2 text-blue-600 hover:text-blue-800 hover:bg-blue-100 rounded-md transition-colors duration-200"
>
{isExpanded ? (
<ChevronUp className="h-5 w-5" />
) : (
<ChevronDown className="h-5 w-5" />
)}
</button>
</div>
</div>
{/* Expanded Session Details */}
{isExpanded && (
<div className="bg-white border-t border-blue-200">
<div className="px-6 py-4">
<h5 className="text-sm font-medium text-gray-900 mb-3">Детализация по зонам</h5>
<div className="space-y-3">
{session.entries.map(entry => (
<div key={entry.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div className="flex items-center gap-3">
<Scissors className="h-4 w-4 text-green-600" />
<div>
<p className="font-medium text-gray-900">{entry.zoneName}</p>
<p className="text-xs text-gray-500">
{entry.zoneArea.toLocaleString()} м2
</p>
</div>
</div>
{entry.duration && (
<div className="text-sm text-gray-600">
{formatDuration(entry.duration)}
</div>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
);
} else {
// This is a single entry
const entry = item as MowingHistory;
return (
<div key={entry.id} className="p-6 hover:bg-gray-50 transition-colors duration-200">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="text-lg font-medium text-gray-900">
{entry.zoneName}
</h4>
<span className="text-sm text-gray-500">
{entry.zoneArea > 0 && `${entry.zoneArea.toLocaleString()} м2`}
</span>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600 mb-3">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>{formatDate(entry.mowedDate)}</span>
<span className="text-gray-400">at {formatTime(entry.mowedDate)}</span>
</div>
{entry.mowerName && (
<div className="flex items-center gap-1">
<span className="text-base">{getMowerIcon(entry.mowerType)}</span>
<span>{entry.mowerName}</span>
</div>
)}
{entry.duration && (
<div className="flex items-center gap-1">
<Timer className="h-4 w-4" />
<span>{formatDuration(entry.duration)}</span>
</div>
)}
{entry.weather && (
<div className="flex items-center gap-1">
<Cloud className="h-4 w-4" />
<span>{entry.weather}</span>
</div>
)}
</div>
{entry.notes && (
<div className="mt-3">
<button
onClick={() => setExpandedEntry(
expandedEntry === entry.id ? null : entry.id
)}
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800 transition-colors duration-200"
>
<FileText className="h-4 w-4" />
<span>Notes</span>
{expandedEntry === entry.id ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{expandedEntry === entry.id && (
<div className="mt-2 p-3 bg-gray-50 rounded-md">
<p className="text-sm text-gray-700">{entry.notes}</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
})}
</div>
{/* Pagination */}
<Pagination
currentPage={historyResponse.pagination.currentPage}
totalPages={historyResponse.pagination.totalPages}
hasNextPage={historyResponse.pagination.hasNextPage}
hasPrevPage={historyResponse.pagination.hasPrevPage}
onPageChange={handlePageChange}
total={historyResponse.pagination.total}
limit={historyResponse.pagination.limit}
/>
</>
)}
</div>
</div>
);
};
export default HistoryView;

View File

@ -2,116 +2,187 @@ import React from 'react';
// Custom SVG icons to replace Lucide React // Custom SVG icons to replace Lucide React
export const Plus: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const Plus: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg> </svg>
); );
export const Filter: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const Filter: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg> </svg>
); );
export const Calendar: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const Calendar: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg> </svg>
); );
export const Scissors: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const Scissors: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="6" cy="6" r="3" /> <circle cx="6" cy="6" r="3" />
<circle cx="6" cy="18" r="3" /> <circle cx="6" cy="18" r="3" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m21 12-7-7m7 7-7 7m7-7H8" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m21 12-7-7m7 7-7 7m7-7H8" />
</svg> </svg>
); );
export const AlertTriangle: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const AlertTriangle: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z" />
</svg> </svg>
); );
export const Square: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const Square: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> <rect x="3" y="3" width="18" height="18" rx="2" ry="2" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg> </svg>
); );
export const CheckCircle: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const CheckCircle: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
); );
export const Clock: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const Clock: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
<polyline points="12,6 12,12 16,14" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> <polyline points="12,6 12,12 16,14" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg> </svg>
); );
export const Map: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const Map: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<polygon points="1,6 1,22 8,18 16,22 23,18 23,2 16,6 8,2" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> <polygon points="1,6 1,22 8,18 16,22 23,18 23,2 16,6 8,2" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<line x1="8" y1="2" x2="8" y2="18" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> <line x1="8" y1="2" x2="8" y2="18" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<line x1="16" y1="6" x2="16" y2="22" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> <line x1="16" y1="6" x2="16" y2="22" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg> </svg>
); );
export const Edit: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const Edit: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg> </svg>
); );
export const Trash2: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const Trash2: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<polyline points="3,6 5,6 21,6" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> <polyline points="3,6 5,6 21,6" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m19,6v14a2,2 0 0,1-2,2H7a2,2 0 0,1-2-2V6m3,0V4a2,2 0 0,1,2-2h4a2,2 0 0,1,2,2v2" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m19,6v14a2,2 0 0,1-2,2H7a2,2 0 0,1-2-2V6m3,0V4a2,2 0 0,1,2-2h4a2,2 0 0,1,2,2v2" />
<line x1="10" y1="11" x2="10" y2="17" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> <line x1="10" y1="11" x2="10" y2="17" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<line x1="14" y1="11" x2="14" y2="17" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> <line x1="14" y1="11" x2="14" y2="17" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg> </svg>
); );
export const Camera: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const Camera: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2v11z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2v11z" />
<circle cx="12" cy="13" r="4" /> <circle cx="12" cy="13" r="4" />
</svg> </svg>
); );
export const X: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const X: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<line x1="18" y1="6" x2="6" y2="18" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> <line x1="18" y1="6" x2="6" y2="18" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<line x1="6" y1="6" x2="18" y2="18" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> <line x1="6" y1="6" x2="18" y2="18" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg> </svg>
); );
export const Upload: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const Upload: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg> </svg>
); );
export const MapPin: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const MapPin: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
<circle cx="12" cy="10" r="3" /> <circle cx="12" cy="10" r="3" />
</svg> </svg>
); );
export const Eye: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const Eye: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
</svg> </svg>
); );
export const EyeOff: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( export const EyeOff: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> <line x1="1" y1="1" x2="23" y2="23" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg> </svg>
);
export const History: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
export const BarChart: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<line x1="12" y1="20" x2="12" y2="10" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<line x1="18" y1="20" x2="18" y2="4" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<line x1="6" y1="20" x2="6" y2="16" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
export const FileText: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14,2 14,8 20,8" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<line x1="16" y1="13" x2="8" y2="13" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<line x1="16" y1="17" x2="8" y2="17" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<polyline points="10,9 9,9 8,9" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
export const Timer: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="13" r="8" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v4l2 2" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 1h6" />
</svg>
);
export const Cloud: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 10h-1.26A8 8 0 109 20h9a5 5 0 000-10z" />
</svg>
);
export const ChevronDown: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<polyline points="6,9 12,15 18,9" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
export const ChevronUp: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<polyline points="18,15 12,9 6,15" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
export const ChevronLeft: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<polyline points="15,18 9,12 15,6" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
export const ChevronRight: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<polyline points="9,18 15,12 9,6" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
export const Users: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M23 21v-2a4 4 0 00-3-3.87" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 3.13a4 4 0 010 7.75" />
</svg>
); );

View File

@ -0,0 +1,211 @@
import React, { useState, useEffect } from 'react';
import { X, Timer, Cloud, FileText, Scissors } from './Icons';
import { MowingFormData, Mower } from '../types/zone';
import { api } from '../services/api';
interface MowingModalProps {
zoneName: string;
onSubmit: (data: MowingFormData) => void;
onCancel: () => void;
loading?: boolean;
}
const MowingModal: React.FC<MowingModalProps> = ({
zoneName,
onSubmit,
onCancel,
loading = false
}) => {
const [formData, setFormData] = useState<MowingFormData>({
notes: '',
duration: undefined,
weather: '',
mowerId: undefined,
});
const [mowers, setMowers] = useState<Mower[]>([]);
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();
onSubmit(formData);
};
const weatherOptions = [
'Солнечно',
'Переменная облачность',
'Облачно',
'Пасмурно',
'Небольшой дождь',
'Ветренно',
'Жарко',
'Холодно',
'Влажно'
];
const getMowerIcon = (type: string) => {
switch (type.toLowerCase()) {
case 'battery':
return '🔋';
case 'electric':
return '⚡';
case 'gas':
case 'petrol':
return '⛽';
default:
return '🚜';
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900">
Отметить как скошенное: {zoneName}
</h2>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600 transition-colors duration-200"
>
<X className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Mower Selection */}
<div>
<label htmlFor="mowerId" className="block text-sm font-medium text-gray-700 mb-2">
<Scissors className="h-4 w-4 inline mr-2" />
Использована косилка
</label>
{loadingMowers ? (
<div className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50">
<span className="text-gray-500">Loading mowers...</span>
</div>
) : (
<select
id="mowerId"
value={formData.mowerId || ''}
onChange={(e) => setFormData(prev => ({
...prev,
mowerId: 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"
required
>
<option value="">Выбор триммера</option>
{mowers.map(mower => (
<option key={mower.id} value={mower.id}>
{getMowerIcon(mower.type)} {mower.name} ({mower.type})
</option>
))}
</select>
)}
{mowers.length === 0 && !loadingMowers && (
<p className="text-xs text-amber-600 mt-1">
No mowers found. You can still record the mowing session without selecting a mower.
</p>
)}
</div>
{/* Duration */}
<div>
<label htmlFor="duration" className="block text-sm font-medium text-gray-700 mb-2">
<Timer className="h-4 w-4 inline mr-2" />
Длительность (минуты)
</label>
<input
type="number"
id="duration"
value={formData.duration || ''}
onChange={(e) => setFormData(prev => ({
...prev,
duration: 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="например, 45"
/>
<p className="text-xs text-gray-500 mt-1">Необязательно - сколько времени это заняло?</p>
</div>
{/* Weather */}
<div>
<label htmlFor="weather" className="block text-sm font-medium text-gray-700 mb-2">
<Cloud className="h-4 w-4 inline mr-2" />
Погодные условия
</label>
<select
id="weather"
value={formData.weather || ''}
onChange={(e) => setFormData(prev => ({ ...prev, weather: e.target.value }))}
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"
>
<option value="">Выбор погоды (не обязательно)</option>
{weatherOptions.map(weather => (
<option key={weather} value={weather}>{weather}</option>
))}
</select>
</div>
{/* Notes */}
<div>
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-2">
<FileText className="h-4 w-4 inline mr-2" />
Заметки
</label>
<textarea
id="notes"
value={formData.notes || ''}
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
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"
rows={3}
placeholder="Какие-либо замечания, проблемы или специальные замечания..."
/>
<p className="text-xs text-gray-500 mt-1">Необязательно - записывайте любые особые наблюдения.</p>
</div>
{/* Form Actions */}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onCancel}
className="flex-1 py-2 px-4 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors duration-200"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="flex-1 py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{loading ? 'Записывается...' : 'Отметить'}
</button>
</div>
</form>
</div>
</div>
);
};
export default MowingModal;

View File

@ -0,0 +1,147 @@
import React from 'react';
import { ChevronLeft, ChevronRight } from './Icons';
interface PaginationProps {
currentPage: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
onPageChange: (page: number) => void;
total: number;
limit: number;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
hasNextPage,
hasPrevPage,
onPageChange,
total,
limit
}) => {
const startItem = (currentPage - 1) * limit + 1;
const endItem = Math.min(currentPage * limit, total);
const getPageNumbers = () => {
const pages = [];
const maxVisiblePages = 5;
if (totalPages <= maxVisiblePages) {
// Show all pages if total is small
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Show smart pagination
if (currentPage <= 3) {
// Show first pages
for (let i = 1; i <= 4; i++) {
pages.push(i);
}
pages.push('...');
pages.push(totalPages);
} else if (currentPage >= totalPages - 2) {
// Show last pages
pages.push(1);
pages.push('...');
for (let i = totalPages - 3; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Show middle pages
pages.push(1);
pages.push('...');
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i);
}
pages.push('...');
pages.push(totalPages);
}
}
return pages;
};
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
<div className="flex flex-1 justify-between sm:hidden">
{/* Mobile pagination */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={!hasPrevPage}
className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={!hasNextPage}
className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{startItem}</span> to{' '}
<span className="font-medium">{endItem}</span> of{' '}
<span className="font-medium">{total}</span> results
</p>
</div>
<div>
<nav className="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
{/* Previous button */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={!hasPrevPage}
className="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">Previous</span>
<ChevronLeft className="h-5 w-5" />
</button>
{/* Page numbers */}
{getPageNumbers().map((page, index) => (
<React.Fragment key={index}>
{page === '...' ? (
<span className="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300 focus:outline-offset-0">
...
</span>
) : (
<button
onClick={() => onPageChange(page as number)}
className={`relative inline-flex items-center px-4 py-2 text-sm font-semibold ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 ${
currentPage === page
? 'z-10 bg-green-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600'
: 'text-gray-900'
}`}
>
{page}
</button>
)}
</React.Fragment>
))}
{/* Next button */}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={!hasNextPage}
className="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">Next</span>
<ChevronRight className="h-5 w-5" />
</button>
</nav>
</div>
</div>
</div>
);
};
export default Pagination;

View File

@ -139,7 +139,7 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
<MapPin className="h-6 w-6 text-blue-600" /> <MapPin className="h-6 w-6 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900">План участка</h3> <h3 className="text-lg font-semibold text-gray-900">План участка</h3>
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
({zoneMarkers.length} of {zones.length} zones marked) ({zoneMarkers.length} из {zones.length} зон отмечено)
</span> </span>
</div> </div>
@ -151,7 +151,7 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
? 'bg-blue-100 text-blue-600' ? 'bg-blue-100 text-blue-600'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`} }`}
title={showMarkers ? 'Hide markers' : 'Show markers'} title={showMarkers ? 'Скрыть маркеры' : 'Показать маркеры'}
> >
{showMarkers ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />} {showMarkers ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button> </button>
@ -164,20 +164,20 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`} }`}
> >
{isEditMode ? 'Done Editing' : 'Edit Markers'} {isEditMode ? 'Редактирование завершено' : 'Редактирование Маркеров'}
</button> </button>
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="px-3 py-2 bg-blue-100 text-blue-700 rounded-md text-sm font-medium hover:bg-blue-200 transition-colors duration-200" className="px-3 py-2 bg-blue-100 text-blue-700 rounded-md text-sm font-medium hover:bg-blue-200 transition-colors duration-200"
> >
Change Image Сменить изображение
</button> </button>
<button <button
onClick={clearSitePlan} onClick={clearSitePlan}
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors duration-200" className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors duration-200"
title="Remove site plan" title="Удалить план участка"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
@ -187,8 +187,8 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
{isEditMode && ( {isEditMode && (
<div className="mt-3 p-3 bg-blue-50 rounded-md"> <div className="mt-3 p-3 bg-blue-50 rounded-md">
<p className="text-sm text-blue-800"> <p className="text-sm text-blue-800">
<strong>Edit Mode:</strong> Click on the image to place markers for zones. <strong>Режим редактора:</strong> Нажмите на изображение, чтобы разместить маркеры для зон.
Zones without markers: {zones.filter(zone => !zoneMarkers.some(marker => marker.id === zone.id)).map(zone => zone.name).join(', ') || 'None'} Зоны без маркеров: {zones.filter(zone => !zoneMarkers.some(marker => marker.id === zone.id)).map(zone => zone.name).join(', ') || 'Нет'}
</p> </p>
</div> </div>
)} )}
@ -272,11 +272,11 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
{/* Legend */} {/* Legend */}
{showMarkers && zoneMarkers.length > 0 && ( {showMarkers && zoneMarkers.length > 0 && (
<div className="p-4 border-t bg-gray-50"> <div className="p-4 border-t bg-gray-50">
<h4 className="text-sm font-medium text-gray-900 mb-3">Zone Status Legend</h4> <h4 className="text-sm font-medium text-gray-900 mb-3">Условные обозначения статуса зон:</h4>
<div className="flex flex-wrap gap-4 text-sm"> <div className="flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500 border border-green-600"></div> <div className="w-3 h-3 rounded-full bg-green-500 border border-green-600"></div>
<span className="text-gray-700">Up to date</span> <span className="text-gray-700">Актуально</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-orange-500 border border-orange-600"></div> <div className="w-3 h-3 rounded-full bg-orange-500 border border-orange-600"></div>
@ -284,7 +284,7 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500 border border-red-600"></div> <div className="w-3 h-3 rounded-full bg-red-500 border border-red-600"></div>
<span className="text-gray-700">Overdue</span> <span className="text-gray-700">Просрочено</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { Scissors, Edit, Trash2, Calendar, Camera, Square } from './Icons'; import { Scissors, Edit, Trash2, Calendar, Camera, Square, Clock, AlertTriangle } from './Icons';
import { Zone } from '../types/zone'; import { Zone } from '../types/zone';
interface ZoneCardProps { interface ZoneCardProps {
zone: Zone; zone: Zone;
onMarkAsMowed: (id: number) => void; onMarkAsMowed: (zone: Zone) => void;
onEdit: (zone: Zone) => void; onEdit: (zone: Zone) => void;
onDelete: (id: number) => void; onDelete: (id: number) => void;
} }
@ -21,14 +21,18 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
return 'border-red-500 bg-red-50'; return 'border-red-500 bg-red-50';
case 'due': case 'due':
return 'border-orange-500 bg-orange-50'; return 'border-orange-500 bg-orange-50';
case 'new':
return 'border-blue-500 bg-blue-50';
default: default:
return 'border-green-500 bg-green-50'; return 'border-green-500 bg-green-50';
} }
}; };
const getStatusText = (zone: Zone) => { const getStatusText = (zone: Zone) => {
if (zone.isOverdue) { if (zone.isNew) {
return `${Math.abs(zone.daysUntilNext)} дней посроченно`; return 'Еще не косилась';
} else if (zone.isOverdue) {
return `${Math.abs(zone.daysUntilNext!)} дней посроченно`;
} else if (zone.isDueToday) { } else if (zone.isDueToday) {
return 'Срок - сегодня'; return 'Срок - сегодня';
} else { } else {
@ -42,14 +46,30 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
return 'text-red-700'; return 'text-red-700';
case 'due': case 'due':
return 'text-orange-700'; return 'text-orange-700';
case 'new':
return 'text-blue-700';
default: default:
return 'text-green-700'; return 'text-green-700';
} }
}; };
const getStatusIcon = (status: string) => {
switch (status) {
case 'overdue':
return <AlertTriangle className="h-4 w-4 text-red-600" />;
case 'due':
return <Clock className="h-4 w-4 text-orange-600" />;
case 'new':
return <Calendar className="h-4 w-4 text-blue-600" />;
default:
return <Scissors className="h-4 w-4 text-green-600" />;
}
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', { if (!dateString) return 'Никогда';
month: 'short', return new Date(dateString).toLocaleDateString('ru-RU', {
month: 'long',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
}); });
@ -60,6 +80,17 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
return `${area.toLocaleString()} м2`; return `${area.toLocaleString()} м2`;
}; };
const getScheduleText = (zone: Zone) => {
if (zone.scheduleType === 'specific') {
if (zone.nextMowDate) {
return `Next: ${formatDate(zone.nextMowDate)}`;
}
return 'Specific date scheduling';
} else {
return zone.intervalDays ? `Каждые ${zone.intervalDays} дней` : 'Не запланированно';
}
};
return ( return (
<div className={`bg-white rounded-lg shadow-md border-l-4 ${getStatusColor(zone.status)} hover:shadow-lg transition-shadow duration-200`}> <div className={`bg-white rounded-lg shadow-md border-l-4 ${getStatusColor(zone.status)} hover:shadow-lg transition-shadow duration-200`}>
{/* Zone Image */} {/* Zone Image */}
@ -76,7 +107,7 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
<div className="h-48 bg-gray-100 rounded-t-lg flex items-center justify-center"> <div className="h-48 bg-gray-100 rounded-t-lg flex items-center justify-center">
<div className="text-center text-gray-400"> <div className="text-center text-gray-400">
<Camera className="h-12 w-12 mx-auto mb-2" /> <Camera className="h-12 w-12 mx-auto mb-2" />
<p className="text-sm">No image</p> <p className="text-sm">Нет изображения</p>
</div> </div>
</div> </div>
)} )}
@ -85,8 +116,9 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
{/* Zone Name and Status */} {/* Zone Name and Status */}
<div className="mb-4"> <div className="mb-4">
<h3 className="text-lg font-semibold text-gray-900 mb-2">{zone.name}</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">{zone.name}</h3>
<div className={`text-sm font-medium ${getStatusTextColor(zone.status)}`}> <div className={`flex items-center gap-2 text-sm font-medium ${getStatusTextColor(zone.status)}`}>
{getStatusText(zone)} {getStatusIcon(zone.status)}
<span>{getStatusText(zone)}</span>
</div> </div>
</div> </div>
@ -94,11 +126,11 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
<div className="space-y-2 mb-4 text-sm text-gray-600"> <div className="space-y-2 mb-4 text-sm text-gray-600">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
<span>Было скошенно: {formatDate(zone.lastMowedDate)}</span> <span>Была скошена: {zone.lastMowedDate ? formatDate(zone.lastMowedDate) : '—'}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Scissors className="h-4 w-4" /> <Clock className="h-4 w-4" />
<span>Каждые {zone.intervalDays} дней</span> <span>{getScheduleText(zone)}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Square className="h-4 w-4" /> <Square className="h-4 w-4" />
@ -109,16 +141,18 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => onMarkAsMowed(zone.id)} onClick={() => onMarkAsMowed(zone)}
disabled={zone.status === 'ok' && zone.daysUntilNext > 1} disabled={zone.status === 'ok' && zone.daysUntilNext! > 1}
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors duration-200 ${ className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors duration-200 ${
zone.status === 'ok' && zone.daysUntilNext > 1 zone.status === 'ok' && zone.daysUntilNext! > 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed' ? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: zone.isNew
? 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm hover:shadow-md'
: 'bg-green-600 hover:bg-green-700 text-white shadow-sm hover:shadow-md' : 'bg-green-600 hover:bg-green-700 text-white shadow-sm hover:shadow-md'
}`} }`}
> >
<Scissors className="h-4 w-4 inline mr-1" /> <Scissors className="h-4 w-4 inline mr-1" />
Скошенно {zone.isNew ? 'Первый покос' : 'Скошенно'}
</button> </button>
<button <button

View File

@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef } from 'react';
import { X, Upload, Camera } from './Icons'; import { X, Upload, Calendar, Clock } from './Icons';
import { Zone, ZoneFormData } from '../types/zone'; import { Zone, ZoneFormData } from '../types/zone';
import { api } from '../services/api'; import { api } from '../services/api';
@ -13,7 +13,9 @@ const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
const [formData, setFormData] = useState<ZoneFormData>({ const [formData, setFormData] = useState<ZoneFormData>({
name: zone?.name || '', name: zone?.name || '',
intervalDays: zone?.intervalDays || 7, intervalDays: zone?.intervalDays || 7,
lastMowedDate: zone ? zone.lastMowedDate.split('T')[0] : new Date().toISOString().split('T')[0], lastMowedDate: zone?.lastMowedDate ? zone.lastMowedDate.split('T')[0] : '',
nextMowDate: zone?.nextMowDate ? zone.nextMowDate.split('T')[0] : '',
scheduleType: zone?.scheduleType || 'interval',
area: zone?.area || 0, area: zone?.area || 0,
}); });
const [imagePreview, setImagePreview] = useState<string | null>( const [imagePreview, setImagePreview] = useState<string | null>(
@ -36,11 +38,33 @@ const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
} }
}; };
const handleScheduleTypeChange = (scheduleType: 'interval' | 'specific') => {
setFormData(prev => ({
...prev,
scheduleType,
// Clear the other scheduling field when switching types
...(scheduleType === 'interval' ? { nextMowDate: '' } : { intervalDays: undefined })
}));
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(null); setError(null);
// Validation
if (formData.scheduleType === 'interval' && (!formData.intervalDays || formData.intervalDays < 1)) {
setError('Please specify a valid mowing interval (1+ days)');
setLoading(false);
return;
}
if (formData.scheduleType === 'specific' && !formData.nextMowDate) {
setError('Please specify the next mowing date');
setLoading(false);
return;
}
try { try {
if (zone) { if (zone) {
await api.updateZone(zone.id, formData); await api.updateZone(zone.id, formData);
@ -60,7 +84,7 @@ const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto"> <div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b"> <div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900"> <h2 className="text-xl font-semibold text-gray-900">
{zone ? 'Редактирование Зоны' : 'Добавление новой Зоны'} {zone ? 'Редактирование Зоны' : 'Создание новой Зоны'}
</h2> </h2>
<button <button
onClick={onCancel} onClick={onCancel}
@ -106,42 +130,111 @@ const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
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" 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="0" min="0"
step="0.1" step="0.1"
placeholder="e.g., 150.5" placeholder="напр., 150.5"
/> />
<p className="text-xs text-gray-500 mt-1">Дополнительно - помогает отслеживать общую площадь газона</p> <p className="text-xs text-gray-500 mt-1">Дополнительно - помогает отслеживать общую площадь газона</p>
</div> </div>
{/* Mowing Interval */} {/* Schedule Type */}
<div> <div>
<label htmlFor="intervalDays" className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-3">
Интервал покоса (дни) Распасание покоса
</label> </label>
<input <div className="space-y-3">
type="number" <label className="flex items-center">
id="intervalDays" <input
value={formData.intervalDays} type="radio"
onChange={(e) => setFormData(prev => ({ ...prev, intervalDays: parseInt(e.target.value) }))} name="scheduleType"
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" value="interval"
required checked={formData.scheduleType === 'interval'}
min="1" onChange={(e) => handleScheduleTypeChange(e.target.value as 'interval')}
max="365" className="mr-3 text-green-600 focus:ring-green-500"
/> />
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-gray-500" />
<span className="text-sm text-gray-700">Регулярный интервал (каждые X дней)</span>
</div>
</label>
<label className="flex items-center">
<input
type="radio"
name="scheduleType"
value="specific"
checked={formData.scheduleType === 'specific'}
onChange={(e) => handleScheduleTypeChange(e.target.value as 'specific')}
className="mr-3 text-green-600 focus:ring-green-500"
/>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-gray-500" />
<span className="text-sm text-gray-700">Определенная дата</span>
</div>
</label>
</div>
</div> </div>
{/* Last Mowed Date */} {/* Conditional Schedule Fields */}
<div> {formData.scheduleType === 'interval' ? (
<label htmlFor="lastMowedDate" className="block text-sm font-medium text-gray-700 mb-2"> <div>
Дата последнего покоса <label htmlFor="intervalDays" className="block text-sm font-medium text-gray-700 mb-2">
</label> Интервал покоса (дни)
<input </label>
type="date" <input
id="lastMowedDate" type="number"
value={formData.lastMowedDate} id="intervalDays"
onChange={(e) => setFormData(prev => ({ ...prev, lastMowedDate: e.target.value }))} value={formData.intervalDays || ''}
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" onChange={(e) => setFormData(prev => ({ ...prev, intervalDays: parseInt(e.target.value) || undefined }))}
required 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"
/> required
</div> min="1"
max="365"
placeholder="напр., 7"
/>
<p className="text-xs text-gray-500 mt-1">Как часто следует косить эту зону?</p>
</div>
) : (
<div>
<label htmlFor="nextMowDate" className="block text-sm font-medium text-gray-700 mb-2">
Дата следующего покоса
</label>
<input
type="date"
id="nextMowDate"
value={formData.nextMowDate || ''}
onChange={(e) => setFormData(prev => ({ ...prev, nextMowDate: e.target.value }))}
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"
required
min={new Date().toISOString().split('T')[0]}
/>
<p className="text-xs text-gray-500 mt-1">When should this zone be mowed next?</p>
</div>
)}
{/* Last Mowed Date (only for editing existing zones) */}
{zone && (
<div>
<label htmlFor="lastMowedDate" className="block text-sm font-medium text-gray-700 mb-2">
Дата последнего покоса
</label>
<input
type="date"
id="lastMowedDate"
value={formData.lastMowedDate || ''}
onChange={(e) => setFormData(prev => ({ ...prev, lastMowedDate: e.target.value }))}
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"
/>
<p className="text-xs text-gray-500 mt-1">Оставьте пустым, если эта зона еще не была скошена</p>
</div>
)}
{/* New Zone Notice */}
{!zone && (
<div className="bg-blue-50 border border-blue-200 rounded-md p-3">
<p className="text-sm text-blue-800">
<strong>Новая зона:</strong> Эта зона будет помечена как "еще не скошенная" до тех пор, пока вы не запишете первый выход на покос.
</p>
</div>
)}
{/* Zone Image */} {/* Zone Image */}
<div> <div>
@ -204,7 +297,7 @@ const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
disabled={loading} disabled={loading}
className="flex-1 py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200" className="flex-1 py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
> >
{loading ? 'Сохраняется...' : zone ? 'Изменение Зоны' : 'Создание Зоны'} {loading ? 'Сохраняется...' : zone ? 'Изменить Зону' : 'Создать Зону'}
</button> </button>
</div> </div>
</form> </form>

View File

@ -1,4 +1,4 @@
import { Zone, ZoneFormData } from '../types/zone'; import { Zone, ZoneFormData, MowingHistory, MowingHistoryResponse, MowingStats, MowingFormData, Mower, MowerFormData, BulkMowingFormData } from '../types/zone';
const API_BASE = 'http://localhost:3001/api'; const API_BASE = 'http://localhost:3001/api';
@ -18,8 +18,15 @@ export const api = {
async createZone(data: ZoneFormData): Promise<Zone> { async createZone(data: ZoneFormData): Promise<Zone> {
const formData = new FormData(); const formData = new FormData();
formData.append('name', data.name); formData.append('name', data.name);
formData.append('intervalDays', data.intervalDays.toString());
formData.append('area', data.area.toString()); formData.append('area', data.area.toString());
formData.append('scheduleType', data.scheduleType);
if (data.scheduleType === 'interval' && data.intervalDays) {
formData.append('intervalDays', data.intervalDays.toString());
} else if (data.scheduleType === 'specific' && data.nextMowDate) {
formData.append('nextMowDate', data.nextMowDate);
}
if (data.image) { if (data.image) {
formData.append('image', data.image); formData.append('image', data.image);
} }
@ -35,9 +42,19 @@ export const api = {
async updateZone(id: number, data: ZoneFormData): Promise<Zone> { async updateZone(id: number, data: ZoneFormData): Promise<Zone> {
const formData = new FormData(); const formData = new FormData();
formData.append('name', data.name); formData.append('name', data.name);
formData.append('intervalDays', data.intervalDays.toString());
formData.append('lastMowedDate', data.lastMowedDate);
formData.append('area', data.area.toString()); formData.append('area', data.area.toString());
formData.append('scheduleType', data.scheduleType);
if (data.lastMowedDate) {
formData.append('lastMowedDate', data.lastMowedDate);
}
if (data.scheduleType === 'interval' && data.intervalDays) {
formData.append('intervalDays', data.intervalDays.toString());
} else if (data.scheduleType === 'specific' && data.nextMowDate) {
formData.append('nextMowDate', data.nextMowDate);
}
if (data.image) { if (data.image) {
formData.append('image', data.image); formData.append('image', data.image);
} }
@ -57,11 +74,64 @@ export const api = {
if (!response.ok) throw new Error('Failed to delete zone'); if (!response.ok) throw new Error('Failed to delete zone');
}, },
async markAsMowed(id: number): Promise<Zone> { async markAsMowed(id: number, data?: MowingFormData): Promise<Zone> {
const response = await fetch(`${API_BASE}/zones/${id}/mow`, { const response = await fetch(`${API_BASE}/zones/${id}/mow`, {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data || {}),
}); });
if (!response.ok) throw new Error('Failed to mark as mowed'); if (!response.ok) throw new Error('Failed to mark as mowed');
return response.json(); return response.json();
}, },
async bulkMarkAsMowed(data: BulkMowingFormData): Promise<void> {
const response = await fetch(`${API_BASE}/zones/bulk-mow`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to record bulk mowing session');
},
async getMowingHistory(zoneId?: number, limit = 10, offset = 0): Promise<MowingHistoryResponse> {
const params = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString(),
});
if (zoneId) {
params.append('zoneId', zoneId.toString());
}
const response = await fetch(`${API_BASE}/history?${params}`);
if (!response.ok) throw new Error('Failed to fetch mowing history');
return response.json();
},
async getMowingStats(period = 30): Promise<MowingStats> {
const response = await fetch(`${API_BASE}/history/stats?period=${period}`);
if (!response.ok) throw new Error('Failed to fetch mowing statistics');
return response.json();
},
async getMowers(): Promise<Mower[]> {
const response = await fetch(`${API_BASE}/mowers`);
if (!response.ok) throw new Error('Failed to fetch mowers');
return response.json();
},
async createMower(data: MowerFormData): Promise<Mower> {
const response = await fetch(`${API_BASE}/mowers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to create mower');
return response.json();
},
}; };

View File

@ -2,21 +2,109 @@ export interface Zone {
id: number; id: number;
name: string; name: string;
imagePath?: string; imagePath?: string;
lastMowedDate: string; lastMowedDate?: string;
intervalDays: number; intervalDays?: number;
nextMowDate?: string;
scheduleType: 'interval' | 'specific';
area: number; area: number;
createdAt: string; createdAt: string;
daysSinceLastMow: number; daysSinceLastMow?: number;
daysUntilNext: number; daysUntilNext?: number;
status: 'ok' | 'due' | 'overdue'; status: 'ok' | 'due' | 'overdue' | 'new';
isOverdue: boolean; isOverdue: boolean;
isDueToday: boolean; isDueToday: boolean;
isNew: boolean;
} }
export interface ZoneFormData { export interface ZoneFormData {
name: string; name: string;
intervalDays: number; intervalDays?: number;
lastMowedDate: string; lastMowedDate?: string;
nextMowDate?: string;
scheduleType: 'interval' | 'specific';
area: number; area: number;
image?: File; image?: File;
} }
export interface Mower {
id: number;
name: string;
type: string;
brand?: string;
model?: string;
isActive: boolean;
createdAt: string;
}
export interface MowerFormData {
name: string;
type: string;
brand?: string;
model?: string;
}
export interface MowingHistory {
id: number;
zoneId: number;
zoneName: string;
zoneArea: number;
mowerId?: number;
mowerName?: string;
mowerType?: string;
mowedDate: string;
notes?: string;
sessionId?: string;
duration?: number; // in minutes
weather?: string;
createdAt: string;
}
export interface MowingHistoryResponse {
data: MowingHistory[];
pagination: {
total: number;
currentPage: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
limit: number;
offset: number;
};
}
export interface MowingStats {
period: number;
totalSessions: number;
totalMinutes: number;
totalArea: number;
mostActiveZone: {
name: string;
sessions: number;
} | null;
mostUsedMower: {
name: string;
type: string;
sessions: number;
} | null;
dailyActivity: {
date: string;
sessions: number;
duration: number;
area: number;
}[];
}
export interface MowingFormData {
notes?: string;
duration?: number;
weather?: string;
mowerId?: number;
}
export interface BulkMowingFormData {
selectedZoneIds: number[];
notes?: string;
totalDuration?: number;
weather?: string;
mowerId?: number;
}