Первая работающая версия 0.1.0. Добавлены групповые сессии. Требуется уточнить перевод на русский терминов.
This commit is contained in:
parent
39c23eef2f
commit
948b2a9f62
Binary file not shown.
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lawn-mowing-scheduler",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
||||
|
560
server/index.js
560
server/index.js
@ -60,19 +60,64 @@ const db = createClient({
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS zones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
imagePath TEXT,
|
||||
lastMowedDate TEXT,
|
||||
intervalDays INTEGER NOT NULL DEFAULT 7,
|
||||
intervalDays INTEGER,
|
||||
nextMowDate TEXT,
|
||||
scheduleType TEXT DEFAULT 'interval',
|
||||
area REAL DEFAULT 0,
|
||||
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
|
||||
try {
|
||||
await db.execute(`ALTER TABLE zones ADD COLUMN area REAL DEFAULT 0`);
|
||||
@ -80,6 +125,36 @@ try {
|
||||
// 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
|
||||
const countResult = await db.execute('SELECT COUNT(*) as count FROM zones');
|
||||
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 twoWeeksAgo = new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.execute({
|
||||
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)',
|
||||
args: ['Front Yard', weekAgo.toISOString(), 7, 150.5]
|
||||
// Get mower IDs for sample data
|
||||
const mowersResult = await db.execute('SELECT id FROM mowers LIMIT 2');
|
||||
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({
|
||||
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)',
|
||||
args: ['Back Yard', twoWeeksAgo.toISOString(), 10, 280.0]
|
||||
const backYardResult = await db.execute({
|
||||
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, scheduleType, area) VALUES (?, ?, ?, ?, ?)',
|
||||
args: ['Back Yard', twoWeeksAgo.toISOString(), 10, 'interval', 280.0]
|
||||
});
|
||||
|
||||
await db.execute({
|
||||
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)',
|
||||
args: ['Side Garden', today.toISOString(), 14, 75.25]
|
||||
const nextWeek = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
const sideGardenResult = await db.execute({
|
||||
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
|
||||
function calculateZoneStatus(zone) {
|
||||
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 daysSinceLastMow = Math.floor((today - lastMowed) / (1000 * 60 * 60 * 24));
|
||||
const daysUntilNext = zone.intervalDays - daysSinceLastMow;
|
||||
|
||||
let daysUntilNext;
|
||||
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) {
|
||||
status = 'overdue';
|
||||
} else if (daysUntilNext <= 0) {
|
||||
@ -127,10 +256,73 @@ function calculateZoneStatus(zone) {
|
||||
daysUntilNext,
|
||||
status,
|
||||
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
|
||||
app.get('/api/zones', async (req, res) => {
|
||||
try {
|
||||
@ -141,6 +333,8 @@ app.get('/api/zones', async (req, res) => {
|
||||
imagePath: row.imagePath,
|
||||
lastMowedDate: row.lastMowedDate,
|
||||
intervalDays: row.intervalDays,
|
||||
nextMowDate: row.nextMowDate,
|
||||
scheduleType: row.scheduleType || 'interval',
|
||||
area: row.area || 0,
|
||||
createdAt: row.createdAt
|
||||
}));
|
||||
@ -168,6 +362,8 @@ app.get('/api/zones/:id', async (req, res) => {
|
||||
imagePath: result.rows[0].imagePath,
|
||||
lastMowedDate: result.rows[0].lastMowedDate,
|
||||
intervalDays: result.rows[0].intervalDays,
|
||||
nextMowDate: result.rows[0].nextMowDate,
|
||||
scheduleType: result.rows[0].scheduleType || 'interval',
|
||||
area: result.rows[0].area || 0,
|
||||
createdAt: result.rows[0].createdAt
|
||||
};
|
||||
@ -179,16 +375,213 @@ app.get('/api/zones/:id', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get mowing history for all zones or specific zone with pagination
|
||||
app.get('/api/history', async (req, res) => {
|
||||
try {
|
||||
const { zoneId, limit = 10, offset = 0 } = req.query;
|
||||
|
||||
// Get total count for pagination
|
||||
let countSql = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM mowing_history mh
|
||||
JOIN zones z ON mh.zoneId = z.id
|
||||
LEFT JOIN mowers m ON mh.mowerId = m.id
|
||||
`;
|
||||
let countArgs = [];
|
||||
|
||||
if (zoneId) {
|
||||
countSql += ' WHERE mh.zoneId = ?';
|
||||
countArgs.push(zoneId);
|
||||
}
|
||||
|
||||
const countResult = await db.execute({ sql: countSql, args: countArgs });
|
||||
const total = countResult.rows[0].total;
|
||||
|
||||
// Get paginated results
|
||||
let sql = `
|
||||
SELECT
|
||||
mh.*,
|
||||
z.name as zoneName,
|
||||
z.area as zoneArea,
|
||||
m.name as mowerName,
|
||||
m.type as mowerType
|
||||
FROM mowing_history mh
|
||||
JOIN zones z ON mh.zoneId = z.id
|
||||
LEFT JOIN mowers m ON mh.mowerId = m.id
|
||||
`;
|
||||
let args = [];
|
||||
|
||||
if (zoneId) {
|
||||
sql += ' WHERE mh.zoneId = ?';
|
||||
args.push(zoneId);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY mh.mowedDate DESC LIMIT ? OFFSET ?';
|
||||
args.push(parseInt(limit), parseInt(offset));
|
||||
|
||||
const result = await db.execute({ sql, args });
|
||||
|
||||
const history = result.rows.map(row => ({
|
||||
id: row.id,
|
||||
zoneId: row.zoneId,
|
||||
zoneName: row.zoneName,
|
||||
zoneArea: row.zoneArea,
|
||||
mowerId: row.mowerId,
|
||||
mowerName: row.mowerName,
|
||||
mowerType: row.mowerType,
|
||||
mowedDate: row.mowedDate,
|
||||
notes: row.notes,
|
||||
duration: row.duration,
|
||||
weather: row.weather,
|
||||
sessionId: row.sessionId,
|
||||
createdAt: row.createdAt
|
||||
}));
|
||||
|
||||
// Calculate pagination info
|
||||
const currentPage = Math.floor(parseInt(offset) / parseInt(limit)) + 1;
|
||||
const totalPages = Math.ceil(total / parseInt(limit));
|
||||
const hasNextPage = currentPage < totalPages;
|
||||
const hasPrevPage = currentPage > 1;
|
||||
|
||||
res.json({
|
||||
data: history,
|
||||
pagination: {
|
||||
total,
|
||||
currentPage,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get mowing statistics
|
||||
app.get('/api/history/stats', async (req, res) => {
|
||||
try {
|
||||
const { period = '30' } = req.query; // days
|
||||
const periodDays = parseInt(period);
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - periodDays);
|
||||
|
||||
// Total mowing sessions
|
||||
const totalResult = await db.execute({
|
||||
sql: `
|
||||
SELECT COUNT(*) as total FROM (
|
||||
SELECT 1 FROM mowing_history
|
||||
WHERE mowedDate >= ? AND sessionId IS NULL
|
||||
UNION ALL
|
||||
SELECT 1 FROM mowing_history
|
||||
WHERE mowedDate >= ? AND sessionId IS NOT NULL
|
||||
GROUP BY sessionId
|
||||
) AS combined
|
||||
`,
|
||||
args: [startDate.toISOString(), startDate.toISOString()]
|
||||
});
|
||||
|
||||
// Total time spent
|
||||
const timeResult = await db.execute({
|
||||
sql: 'SELECT SUM(duration) as totalMinutes FROM mowing_history WHERE mowedDate >= ? AND duration IS NOT NULL',
|
||||
args: [startDate.toISOString()]
|
||||
});
|
||||
|
||||
// Total area mowed
|
||||
const areaResult = await db.execute({
|
||||
sql: `
|
||||
SELECT SUM(z.area) as totalArea
|
||||
FROM mowing_history mh
|
||||
JOIN zones z ON mh.zoneId = z.id
|
||||
WHERE mh.mowedDate >= ?
|
||||
`,
|
||||
args: [startDate.toISOString()]
|
||||
});
|
||||
|
||||
// Most active zone
|
||||
const activeZoneResult = await db.execute({
|
||||
sql: `
|
||||
SELECT z.name, COUNT(*) as sessions
|
||||
FROM mowing_history mh
|
||||
JOIN zones z ON mh.zoneId = z.id
|
||||
WHERE mh.mowedDate >= ?
|
||||
GROUP BY mh.zoneId, z.name
|
||||
ORDER BY sessions DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
args: [startDate.toISOString()]
|
||||
});
|
||||
|
||||
// Most used mower
|
||||
const activeMowerResult = await db.execute({
|
||||
sql: `
|
||||
SELECT m.name, m.type, COUNT(*) as sessions
|
||||
FROM mowing_history mh
|
||||
JOIN mowers m ON mh.mowerId = m.id
|
||||
WHERE mh.mowedDate >= ?
|
||||
GROUP BY mh.mowerId, m.name, m.type
|
||||
ORDER BY sessions DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
args: [startDate.toISOString()]
|
||||
});
|
||||
|
||||
// Daily activity for chart
|
||||
const dailyResult = await db.execute({
|
||||
sql: `
|
||||
SELECT
|
||||
DATE(mowedDate) as date,
|
||||
COUNT(*) as sessions,
|
||||
SUM(duration) as totalDuration,
|
||||
SUM(z.area) as totalArea
|
||||
FROM mowing_history mh
|
||||
JOIN zones z ON mh.zoneId = z.id
|
||||
WHERE mowedDate >= ?
|
||||
GROUP BY DATE(mowedDate)
|
||||
ORDER BY date DESC
|
||||
LIMIT 30
|
||||
`,
|
||||
args: [startDate.toISOString()]
|
||||
});
|
||||
|
||||
res.json({
|
||||
period: periodDays,
|
||||
totalSessions: totalResult.rows[0].total,
|
||||
totalMinutes: timeResult.rows[0].totalMinutes || 0,
|
||||
totalArea: areaResult.rows[0].totalArea || 0,
|
||||
mostActiveZone: activeZoneResult.rows[0] || null,
|
||||
mostUsedMower: activeMowerResult.rows[0] || null,
|
||||
dailyActivity: dailyResult.rows.map(row => ({
|
||||
date: row.date,
|
||||
sessions: row.sessions,
|
||||
duration: row.totalDuration || 0,
|
||||
area: row.totalArea || 0
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/zones', upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const { name, intervalDays, area } = req.body;
|
||||
const { name, intervalDays, nextMowDate, scheduleType, area } = req.body;
|
||||
const imagePath = req.file ? `/uploads/${req.file.filename}` : null;
|
||||
const lastMowedDate = new Date().toISOString();
|
||||
|
||||
const result = await db.execute({
|
||||
sql: 'INSERT INTO zones (name, imagePath, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?, ?)',
|
||||
args: [name, imagePath, lastMowedDate, parseInt(intervalDays), parseFloat(area) || 0]
|
||||
});
|
||||
// For new zones, don't set lastMowedDate (they haven't been mowed yet)
|
||||
let sql, args;
|
||||
|
||||
if (scheduleType === 'specific') {
|
||||
sql = 'INSERT INTO zones (name, imagePath, nextMowDate, scheduleType, area) VALUES (?, ?, ?, ?, ?)';
|
||||
args = [name, imagePath, nextMowDate, scheduleType, parseFloat(area) || 0];
|
||||
} else {
|
||||
sql = 'INSERT INTO zones (name, imagePath, intervalDays, scheduleType, area) VALUES (?, ?, ?, ?, ?)';
|
||||
args = [name, imagePath, parseInt(intervalDays), scheduleType || 'interval', parseFloat(area) || 0];
|
||||
}
|
||||
|
||||
const result = await db.execute({ sql, args });
|
||||
|
||||
const newZoneResult = await db.execute({
|
||||
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||
@ -201,6 +594,8 @@ app.post('/api/zones', upload.single('image'), async (req, res) => {
|
||||
imagePath: newZoneResult.rows[0].imagePath,
|
||||
lastMowedDate: newZoneResult.rows[0].lastMowedDate,
|
||||
intervalDays: newZoneResult.rows[0].intervalDays,
|
||||
nextMowDate: newZoneResult.rows[0].nextMowDate,
|
||||
scheduleType: newZoneResult.rows[0].scheduleType || 'interval',
|
||||
area: newZoneResult.rows[0].area || 0,
|
||||
createdAt: newZoneResult.rows[0].createdAt
|
||||
};
|
||||
@ -214,7 +609,7 @@ app.post('/api/zones', upload.single('image'), async (req, res) => {
|
||||
|
||||
app.put('/api/zones/:id', upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const { name, intervalDays, lastMowedDate, area } = req.body;
|
||||
const { name, intervalDays, lastMowedDate, nextMowDate, scheduleType, area } = req.body;
|
||||
|
||||
const existingResult = await db.execute({
|
||||
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||
@ -228,10 +623,17 @@ app.put('/api/zones/:id', upload.single('image'), async (req, res) => {
|
||||
const existingZone = existingResult.rows[0];
|
||||
const imagePath = req.file ? `/uploads/${req.file.filename}` : existingZone.imagePath;
|
||||
|
||||
await db.execute({
|
||||
sql: 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, intervalDays = ?, area = ? WHERE id = ?',
|
||||
args: [name, imagePath, lastMowedDate, parseInt(intervalDays), parseFloat(area) || 0, req.params.id]
|
||||
});
|
||||
let sql, args;
|
||||
|
||||
if (scheduleType === 'specific') {
|
||||
sql = 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, nextMowDate = ?, scheduleType = ?, intervalDays = NULL, area = ? WHERE id = ?';
|
||||
args = [name, imagePath, lastMowedDate || null, nextMowDate, scheduleType, parseFloat(area) || 0, req.params.id];
|
||||
} else {
|
||||
sql = 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, intervalDays = ?, scheduleType = ?, nextMowDate = NULL, area = ? WHERE id = ?';
|
||||
args = [name, imagePath, lastMowedDate || null, parseInt(intervalDays), scheduleType || 'interval', parseFloat(area) || 0, req.params.id];
|
||||
}
|
||||
|
||||
await db.execute({ sql, args });
|
||||
|
||||
// Delete old image if new one was provided
|
||||
if (req.file && existingZone.imagePath) {
|
||||
@ -252,6 +654,8 @@ app.put('/api/zones/:id', upload.single('image'), async (req, res) => {
|
||||
imagePath: updatedResult.rows[0].imagePath,
|
||||
lastMowedDate: updatedResult.rows[0].lastMowedDate,
|
||||
intervalDays: updatedResult.rows[0].intervalDays,
|
||||
nextMowDate: updatedResult.rows[0].nextMowDate,
|
||||
scheduleType: updatedResult.rows[0].scheduleType || 'interval',
|
||||
area: updatedResult.rows[0].area || 0,
|
||||
createdAt: updatedResult.rows[0].createdAt
|
||||
};
|
||||
@ -297,11 +701,40 @@ app.delete('/api/zones/:id', async (req, res) => {
|
||||
|
||||
app.post('/api/zones/:id/mow', async (req, res) => {
|
||||
try {
|
||||
const { notes, duration, weather, mowerId } = req.body;
|
||||
const today = new Date().toISOString();
|
||||
|
||||
// Get current zone data
|
||||
const zoneResult = await db.execute({
|
||||
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||
args: [req.params.id]
|
||||
});
|
||||
|
||||
if (zoneResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Zone not found' });
|
||||
}
|
||||
|
||||
const zone = zoneResult.rows[0];
|
||||
|
||||
// Update zone's last mowed date and calculate next mow date if using interval scheduling
|
||||
let sql, args;
|
||||
|
||||
if (zone.scheduleType === 'interval' && zone.intervalDays) {
|
||||
const nextMowDate = calculateNextMowDate(today, zone.intervalDays);
|
||||
sql = 'UPDATE zones SET lastMowedDate = ?, nextMowDate = ? WHERE id = ?';
|
||||
args = [today, nextMowDate, req.params.id];
|
||||
} else {
|
||||
// For specific date scheduling, just update last mowed date
|
||||
sql = 'UPDATE zones SET lastMowedDate = ? WHERE id = ?';
|
||||
args = [today, req.params.id];
|
||||
}
|
||||
|
||||
await db.execute({ sql, args });
|
||||
|
||||
// Add to mowing history
|
||||
await db.execute({
|
||||
sql: 'UPDATE zones SET lastMowedDate = ? WHERE id = ?',
|
||||
args: [today, req.params.id]
|
||||
sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
args: [req.params.id, mowerId || null, today, notes || null, duration || null, weather || null]
|
||||
});
|
||||
|
||||
const updatedResult = await db.execute({
|
||||
@ -315,6 +748,8 @@ app.post('/api/zones/:id/mow', async (req, res) => {
|
||||
imagePath: updatedResult.rows[0].imagePath,
|
||||
lastMowedDate: updatedResult.rows[0].lastMowedDate,
|
||||
intervalDays: updatedResult.rows[0].intervalDays,
|
||||
nextMowDate: updatedResult.rows[0].nextMowDate,
|
||||
scheduleType: updatedResult.rows[0].scheduleType || 'interval',
|
||||
area: updatedResult.rows[0].area || 0,
|
||||
createdAt: updatedResult.rows[0].createdAt
|
||||
};
|
||||
@ -326,6 +761,79 @@ app.post('/api/zones/:id/mow', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk mowing endpoint
|
||||
app.post('/api/zones/bulk-mow', async (req, res) => {
|
||||
try {
|
||||
const { selectedZoneIds, notes, totalDuration, weather, mowerId } = req.body;
|
||||
|
||||
if (!selectedZoneIds || selectedZoneIds.length === 0) {
|
||||
return res.status(400).json({ error: 'No zones selected' });
|
||||
}
|
||||
|
||||
const today = new Date().toISOString();
|
||||
const sessionId = generateSessionId();
|
||||
|
||||
// Get zone data for selected zones
|
||||
const zoneResults = await Promise.all(
|
||||
selectedZoneIds.map(id =>
|
||||
db.execute({
|
||||
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||
args: [id]
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const zones = zoneResults.map(result => result.rows[0]).filter(Boolean);
|
||||
|
||||
if (zones.length === 0) {
|
||||
return res.status(404).json({ error: 'No valid zones found' });
|
||||
}
|
||||
|
||||
// Calculate total area for proportional time distribution
|
||||
const totalArea = zones.reduce((sum, zone) => sum + (zone.area || 0), 0);
|
||||
|
||||
// Process each zone
|
||||
for (const zone of zones) {
|
||||
// Calculate proportional duration
|
||||
let zoneDuration = null;
|
||||
if (totalDuration && totalArea > 0 && zone.area > 0) {
|
||||
const proportion = zone.area / totalArea;
|
||||
zoneDuration = Math.round(totalDuration * proportion);
|
||||
}
|
||||
|
||||
// Update zone's last mowed date and calculate next mow date if using interval scheduling
|
||||
let sql, args;
|
||||
|
||||
if (zone.scheduleType === 'interval' && zone.intervalDays) {
|
||||
const nextMowDate = calculateNextMowDate(today, zone.intervalDays);
|
||||
sql = 'UPDATE zones SET lastMowedDate = ?, nextMowDate = ? WHERE id = ?';
|
||||
args = [today, nextMowDate, zone.id];
|
||||
} else {
|
||||
// For specific date scheduling, just update last mowed date
|
||||
sql = 'UPDATE zones SET lastMowedDate = ? WHERE id = ?';
|
||||
args = [today, zone.id];
|
||||
}
|
||||
|
||||
await db.execute({ sql, args });
|
||||
|
||||
// Add to mowing history with session ID
|
||||
await db.execute({
|
||||
sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather, sessionId) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
args: [zone.id, mowerId || null, today, notes || null, zoneDuration, weather || null, sessionId]
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Successfully recorded mowing session for ${zones.length} zones`,
|
||||
sessionId,
|
||||
zonesUpdated: zones.length
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve React app in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.get('*', (req, res) => {
|
||||
|
358
src/components/BulkMowingModal.tsx
Normal file
358
src/components/BulkMowingModal.tsx
Normal 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;
|
@ -1,13 +1,16 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Filter, Calendar, Scissors, AlertTriangle, Square, CheckCircle, Clock, Map } from './Icons';
|
||||
import { Zone } from '../types/zone';
|
||||
import { Plus, Filter, Calendar, Scissors, AlertTriangle, Square, CheckCircle, Clock, Map, History } from './Icons';
|
||||
import { Zone, MowingFormData, BulkMowingFormData } from '../types/zone';
|
||||
import { api } from '../services/api';
|
||||
import ZoneCard from './ZoneCard';
|
||||
import ZoneForm from './ZoneForm';
|
||||
import SitePlan from './SitePlan';
|
||||
import HistoryView from './HistoryView';
|
||||
import MowingModal from './MowingModal';
|
||||
import BulkMowingModal from "./BulkMowingModal";
|
||||
|
||||
type FilterType = 'all' | 'due' | 'overdue';
|
||||
type ViewType = 'dashboard' | 'sitePlan';
|
||||
type FilterType = 'all' | 'due' | 'overdue' | 'new';
|
||||
type ViewType = 'dashboard' | 'sitePlan' | 'history';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const [zones, setZones] = useState<Zone[]>([]);
|
||||
@ -17,7 +20,11 @@ const Dashboard: React.FC = () => {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingZone, setEditingZone] = useState<Zone | null>(null);
|
||||
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 [mowingLoading, setMowingLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadZones();
|
||||
@ -47,18 +54,46 @@ const Dashboard: React.FC = () => {
|
||||
case 'overdue':
|
||||
filtered = zones.filter(zone => zone.isOverdue);
|
||||
break;
|
||||
case 'new':
|
||||
filtered = zones.filter(zone => zone.isNew);
|
||||
break;
|
||||
default:
|
||||
filtered = zones;
|
||||
}
|
||||
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 {
|
||||
await api.markAsMowed(id);
|
||||
await api.markAsMowed(mowingZone.id, data);
|
||||
setShowMowingModal(false);
|
||||
setMowingZone(null);
|
||||
loadZones();
|
||||
} catch (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
|
||||
const overdueCount = zones.filter(zone => zone.isOverdue).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 totalArea = zones.reduce((sum, zone) => sum + zone.area, 0);
|
||||
|
||||
@ -105,12 +141,13 @@ const Dashboard: React.FC = () => {
|
||||
.filter(zone => zone.status === 'ok')
|
||||
.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;
|
||||
|
||||
// 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) {
|
||||
return (
|
||||
<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" />
|
||||
План участка
|
||||
</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>
|
||||
|
||||
<button
|
||||
@ -170,7 +218,9 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === 'sitePlan' ? (
|
||||
{view === 'history' ? (
|
||||
<HistoryView zones={zones} />
|
||||
) : view === 'sitePlan' ? (
|
||||
<SitePlan
|
||||
zones={zones}
|
||||
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="flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
<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="flex items-center justify-between">
|
||||
<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-xs text-gray-500">м2</p>
|
||||
</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="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">{mowedArea.toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-500">м2 ({mowedPercentage.toFixed(1)}%)</p>
|
||||
<p className="text-sm font-medium text-gray-600">Актуально</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{okCount}</p>
|
||||
<p className="text-xs text-gray-500">обслужено зон</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-emerald-500" />
|
||||
</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="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">{remainingArea.toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-500">м2 ({(100 - mowedPercentage).toFixed(1)}%)</p>
|
||||
<p className="text-sm font-medium text-gray-600">Внимание!</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{dueCount + overdueCount}</p>
|
||||
<p className="text-xs text-gray-500">зон для покоса</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-amber-500" />
|
||||
</div>
|
||||
@ -262,16 +325,41 @@ const Dashboard: React.FC = () => {
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle className="h-4 w-4 text-emerald-500" />
|
||||
{mowedPercentage.toFixed(1)}% Завершено
|
||||
{mowedPercentage.toFixed(1)}% Завершено ({okCount} зон)
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4 text-amber-500" />
|
||||
{(100 - mowedPercentage).toFixed(1)}% Осталось
|
||||
{(100 - mowedPercentage).toFixed(1)}% Осталось ({dueCount + overdueCount} зон)
|
||||
</span>
|
||||
</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 */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
@ -280,6 +368,7 @@ const Dashboard: React.FC = () => {
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ key: 'all' as FilterType, label: 'Все зоны', count: zones.length },
|
||||
{ key: 'new' as FilterType, label: 'Новые зоны', count: newCount },
|
||||
{ key: 'due' as FilterType, label: 'Срок - сегодня', count: dueCount },
|
||||
{ key: 'overdue' as FilterType, label: 'Срок прошел', count: overdueCount },
|
||||
].map(({ key, label, count }) => (
|
||||
@ -303,9 +392,9 @@ const Dashboard: React.FC = () => {
|
||||
{filteredZones.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<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">
|
||||
{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>
|
||||
</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>
|
||||
);
|
||||
|
562
src/components/HistoryView.tsx
Normal file
562
src/components/HistoryView.tsx
Normal 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;
|
||||
|
@ -2,116 +2,187 @@ import React from 'react';
|
||||
|
||||
// Custom SVG icons to replace Lucide React
|
||||
export const Plus: 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 4v16m8-8H4" />
|
||||
</svg>
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Filter: 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="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 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" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Calendar: 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="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 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" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Scissors: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="6" cy="6" 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" />
|
||||
</svg>
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="6" cy="6" 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" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const AlertTriangle: 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 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 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" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Square: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||
<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} />
|
||||
</svg>
|
||||
<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} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CheckCircle: 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<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" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Clock: 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="12" r="10" />
|
||||
<polyline points="12,6 12,12 16,14" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||
</svg>
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12,6 12,12 16,14" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Map: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||
<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} />
|
||||
<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} />
|
||||
</svg>
|
||||
<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} />
|
||||
<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} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Edit: 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="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 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" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Trash2: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||
<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} />
|
||||
<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="14" y1="11" x2="14" y2="17" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||
</svg>
|
||||
<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} />
|
||||
<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="14" y1="11" x2="14" y2="17" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Camera: 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="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" />
|
||||
</svg>
|
||||
<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" />
|
||||
<circle cx="12" cy="13" r="4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const X: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||
<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="6" y1="6" x2="18" y2="18" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||
</svg>
|
||||
<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="6" y1="6" x2="18" y2="18" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Upload: 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="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 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" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const MapPin: 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="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
<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" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Eye: 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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<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" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const EyeOff: 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.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} />
|
||||
</svg>
|
||||
<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" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||
</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>
|
||||
);
|
211
src/components/MowingModal.tsx
Normal file
211
src/components/MowingModal.tsx
Normal 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;
|
147
src/components/Pagination.tsx
Normal file
147
src/components/Pagination.tsx
Normal 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;
|
@ -139,7 +139,7 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
|
||||
<MapPin className="h-6 w-6 text-blue-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">План участка</h3>
|
||||
<span className="text-sm text-gray-500">
|
||||
({zoneMarkers.length} of {zones.length} zones marked)
|
||||
({zoneMarkers.length} из {zones.length} зон отмечено)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -151,7 +151,7 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
|
||||
? 'bg-blue-100 text-blue-600'
|
||||
: '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" />}
|
||||
</button>
|
||||
@ -164,20 +164,20 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{isEditMode ? 'Done Editing' : 'Edit Markers'}
|
||||
{isEditMode ? 'Редактирование завершено' : 'Редактирование Маркеров'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Change Image
|
||||
Сменить изображение
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={clearSitePlan}
|
||||
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" />
|
||||
</button>
|
||||
@ -187,8 +187,8 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
|
||||
{isEditMode && (
|
||||
<div className="mt-3 p-3 bg-blue-50 rounded-md">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Edit Mode:</strong> Click on the image to place markers for zones.
|
||||
Zones without markers: {zones.filter(zone => !zoneMarkers.some(marker => marker.id === zone.id)).map(zone => zone.name).join(', ') || 'None'}
|
||||
<strong>Режим редактора:</strong> Нажмите на изображение, чтобы разместить маркеры для зон.
|
||||
Зоны без маркеров: {zones.filter(zone => !zoneMarkers.some(marker => marker.id === zone.id)).map(zone => zone.name).join(', ') || 'Нет'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -272,11 +272,11 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
|
||||
{/* Legend */}
|
||||
{showMarkers && zoneMarkers.length > 0 && (
|
||||
<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 items-center gap-2">
|
||||
<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 className="flex items-center gap-2">
|
||||
<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 className="flex items-center gap-2">
|
||||
<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>
|
||||
|
@ -1,10 +1,10 @@
|
||||
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';
|
||||
|
||||
interface ZoneCardProps {
|
||||
zone: Zone;
|
||||
onMarkAsMowed: (id: number) => void;
|
||||
onMarkAsMowed: (zone: Zone) => void;
|
||||
onEdit: (zone: Zone) => void;
|
||||
onDelete: (id: number) => void;
|
||||
}
|
||||
@ -21,14 +21,18 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
||||
return 'border-red-500 bg-red-50';
|
||||
case 'due':
|
||||
return 'border-orange-500 bg-orange-50';
|
||||
case 'new':
|
||||
return 'border-blue-500 bg-blue-50';
|
||||
default:
|
||||
return 'border-green-500 bg-green-50';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (zone: Zone) => {
|
||||
if (zone.isOverdue) {
|
||||
return `${Math.abs(zone.daysUntilNext)} дней посроченно`;
|
||||
if (zone.isNew) {
|
||||
return 'Еще не косилась';
|
||||
} else if (zone.isOverdue) {
|
||||
return `${Math.abs(zone.daysUntilNext!)} дней посроченно`;
|
||||
} else if (zone.isDueToday) {
|
||||
return 'Срок - сегодня';
|
||||
} else {
|
||||
@ -42,14 +46,30 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
||||
return 'text-red-700';
|
||||
case 'due':
|
||||
return 'text-orange-700';
|
||||
case 'new':
|
||||
return 'text-blue-700';
|
||||
default:
|
||||
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) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
if (!dateString) return 'Никогда';
|
||||
return new Date(dateString).toLocaleDateString('ru-RU', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
@ -60,6 +80,17 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
||||
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 (
|
||||
<div className={`bg-white rounded-lg shadow-md border-l-4 ${getStatusColor(zone.status)} hover:shadow-lg transition-shadow duration-200`}>
|
||||
{/* 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="text-center text-gray-400">
|
||||
<Camera className="h-12 w-12 mx-auto mb-2" />
|
||||
<p className="text-sm">No image</p>
|
||||
<p className="text-sm">Нет изображения</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -85,8 +116,9 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
||||
{/* Zone Name and Status */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{zone.name}</h3>
|
||||
<div className={`text-sm font-medium ${getStatusTextColor(zone.status)}`}>
|
||||
{getStatusText(zone)}
|
||||
<div className={`flex items-center gap-2 text-sm font-medium ${getStatusTextColor(zone.status)}`}>
|
||||
{getStatusIcon(zone.status)}
|
||||
<span>{getStatusText(zone)}</span>
|
||||
</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="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Было скошенно: {formatDate(zone.lastMowedDate)}</span>
|
||||
<span>Была скошена: {zone.lastMowedDate ? formatDate(zone.lastMowedDate) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Scissors className="h-4 w-4" />
|
||||
<span>Каждые {zone.intervalDays} дней</span>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{getScheduleText(zone)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Square className="h-4 w-4" />
|
||||
@ -109,16 +141,18 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onMarkAsMowed(zone.id)}
|
||||
disabled={zone.status === 'ok' && zone.daysUntilNext > 1}
|
||||
onClick={() => onMarkAsMowed(zone)}
|
||||
disabled={zone.status === 'ok' && zone.daysUntilNext! > 1}
|
||||
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'
|
||||
: 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'
|
||||
}`}
|
||||
>
|
||||
<Scissors className="h-4 w-4 inline mr-1" />
|
||||
Скошенно
|
||||
{zone.isNew ? 'Первый покос' : 'Скошенно'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { X, Upload, Camera } from './Icons';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { X, Upload, Calendar, Clock } from './Icons';
|
||||
import { Zone, ZoneFormData } from '../types/zone';
|
||||
import { api } from '../services/api';
|
||||
|
||||
@ -13,7 +13,9 @@ const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
|
||||
const [formData, setFormData] = useState<ZoneFormData>({
|
||||
name: zone?.name || '',
|
||||
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,
|
||||
});
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
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 {
|
||||
if (zone) {
|
||||
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="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{zone ? 'Редактирование Зоны' : 'Добавление новой Зоны'}
|
||||
{zone ? 'Редактирование Зоны' : 'Создание новой Зоны'}
|
||||
</h2>
|
||||
<button
|
||||
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"
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="e.g., 150.5"
|
||||
placeholder="напр., 150.5"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Дополнительно - помогает отслеживать общую площадь газона</p>
|
||||
</div>
|
||||
|
||||
{/* Mowing Interval */}
|
||||
{/* Schedule Type */}
|
||||
<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>
|
||||
<input
|
||||
type="number"
|
||||
id="intervalDays"
|
||||
value={formData.intervalDays}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, intervalDays: parseInt(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="1"
|
||||
max="365"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="scheduleType"
|
||||
value="interval"
|
||||
checked={formData.scheduleType === 'interval'}
|
||||
onChange={(e) => handleScheduleTypeChange(e.target.value as 'interval')}
|
||||
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>
|
||||
|
||||
{/* Last Mowed Date */}
|
||||
<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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/* Conditional Schedule Fields */}
|
||||
{formData.scheduleType === 'interval' ? (
|
||||
<div>
|
||||
<label htmlFor="intervalDays" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Интервал покоса (дни)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="intervalDays"
|
||||
value={formData.intervalDays || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, intervalDays: 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
|
||||
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 */}
|
||||
<div>
|
||||
@ -204,7 +297,7 @@ const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
|
||||
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 ? 'Сохраняется...' : zone ? 'Изменение Зоны' : 'Создание Зоны'}
|
||||
{loading ? 'Сохраняется...' : zone ? 'Изменить Зону' : 'Создать Зону'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -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';
|
||||
|
||||
@ -18,8 +18,15 @@ export const api = {
|
||||
async createZone(data: ZoneFormData): Promise<Zone> {
|
||||
const formData = new FormData();
|
||||
formData.append('name', data.name);
|
||||
formData.append('intervalDays', data.intervalDays.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) {
|
||||
formData.append('image', data.image);
|
||||
}
|
||||
@ -35,9 +42,19 @@ export const api = {
|
||||
async updateZone(id: number, data: ZoneFormData): Promise<Zone> {
|
||||
const formData = new FormData();
|
||||
formData.append('name', data.name);
|
||||
formData.append('intervalDays', data.intervalDays.toString());
|
||||
formData.append('lastMowedDate', data.lastMowedDate);
|
||||
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) {
|
||||
formData.append('image', data.image);
|
||||
}
|
||||
@ -57,11 +74,64 @@ export const api = {
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data || {}),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to mark as mowed');
|
||||
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();
|
||||
},
|
||||
};
|
@ -2,21 +2,109 @@ export interface Zone {
|
||||
id: number;
|
||||
name: string;
|
||||
imagePath?: string;
|
||||
lastMowedDate: string;
|
||||
intervalDays: number;
|
||||
lastMowedDate?: string;
|
||||
intervalDays?: number;
|
||||
nextMowDate?: string;
|
||||
scheduleType: 'interval' | 'specific';
|
||||
area: number;
|
||||
createdAt: string;
|
||||
daysSinceLastMow: number;
|
||||
daysUntilNext: number;
|
||||
status: 'ok' | 'due' | 'overdue';
|
||||
daysSinceLastMow?: number;
|
||||
daysUntilNext?: number;
|
||||
status: 'ok' | 'due' | 'overdue' | 'new';
|
||||
isOverdue: boolean;
|
||||
isDueToday: boolean;
|
||||
isNew: boolean;
|
||||
}
|
||||
|
||||
export interface ZoneFormData {
|
||||
name: string;
|
||||
intervalDays: number;
|
||||
lastMowedDate: string;
|
||||
intervalDays?: number;
|
||||
lastMowedDate?: string;
|
||||
nextMowDate?: string;
|
||||
scheduleType: 'interval' | 'specific';
|
||||
area: number;
|
||||
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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user