Первая работающая версия 0.1.0. Добавлены групповые сессии. Требуется уточнить перевод на русский терминов.
This commit is contained in:
parent
39c23eef2f
commit
948b2a9f62
Binary file not shown.
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "lawn-mowing-scheduler",
|
"name": "lawn-mowing-scheduler",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
||||||
|
560
server/index.js
560
server/index.js
@ -60,19 +60,64 @@ const db = createClient({
|
|||||||
url: 'file:lawn_scheduler.db'
|
url: 'file:lawn_scheduler.db'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create zones table with area field
|
// Create mowers table
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS mowers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
brand TEXT,
|
||||||
|
model TEXT,
|
||||||
|
isActive BOOLEAN DEFAULT 1,
|
||||||
|
createdAt TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create zones table with updated schema
|
||||||
await db.execute(`
|
await db.execute(`
|
||||||
CREATE TABLE IF NOT EXISTS zones (
|
CREATE TABLE IF NOT EXISTS zones (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
imagePath TEXT,
|
imagePath TEXT,
|
||||||
lastMowedDate TEXT,
|
lastMowedDate TEXT,
|
||||||
intervalDays INTEGER NOT NULL DEFAULT 7,
|
intervalDays INTEGER,
|
||||||
|
nextMowDate TEXT,
|
||||||
|
scheduleType TEXT DEFAULT 'interval',
|
||||||
area REAL DEFAULT 0,
|
area REAL DEFAULT 0,
|
||||||
createdAt TEXT DEFAULT CURRENT_TIMESTAMP
|
createdAt TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Create mowing history table
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS mowing_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
zoneId INTEGER NOT NULL,
|
||||||
|
mowerId INTEGER,
|
||||||
|
mowedDate TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
duration INTEGER, -- in minutes
|
||||||
|
weather TEXT,
|
||||||
|
sessionId TEXT, -- for grouping bulk mowing sessions
|
||||||
|
createdAt TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (zoneId) REFERENCES zones (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (mowerId) REFERENCES mowers (id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add new columns to existing zones table if they don't exist
|
||||||
|
try {
|
||||||
|
await db.execute(`ALTER TABLE zones ADD COLUMN nextMowDate TEXT`);
|
||||||
|
} catch (error) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.execute(`ALTER TABLE zones ADD COLUMN scheduleType TEXT DEFAULT 'interval'`);
|
||||||
|
} catch (error) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
// Add area column to existing table if it doesn't exist
|
// Add area column to existing table if it doesn't exist
|
||||||
try {
|
try {
|
||||||
await db.execute(`ALTER TABLE zones ADD COLUMN area REAL DEFAULT 0`);
|
await db.execute(`ALTER TABLE zones ADD COLUMN area REAL DEFAULT 0`);
|
||||||
@ -80,6 +125,36 @@ try {
|
|||||||
// Column already exists, ignore error
|
// Column already exists, ignore error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add mowerId column to existing mowing_history table if it doesn't exist
|
||||||
|
try {
|
||||||
|
await db.execute(`ALTER TABLE mowing_history ADD COLUMN mowerId INTEGER REFERENCES mowers (id) ON DELETE SET NULL`);
|
||||||
|
} catch (error) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sessionId column to existing mowing_history table if it doesn't exist
|
||||||
|
try {
|
||||||
|
await db.execute(`ALTER TABLE mowing_history ADD COLUMN sessionId TEXT`);
|
||||||
|
} catch (error) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert default mowers if table is empty
|
||||||
|
const mowerCountResult = await db.execute('SELECT COUNT(*) as count FROM mowers');
|
||||||
|
const mowerCount = mowerCountResult.rows[0].count;
|
||||||
|
|
||||||
|
if (mowerCount === 0) {
|
||||||
|
await db.execute({
|
||||||
|
sql: 'INSERT INTO mowers (name, type, brand, model) VALUES (?, ?, ?, ?)',
|
||||||
|
args: ['Battery Mower', 'Battery', 'Generic', 'Battery Model']
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.execute({
|
||||||
|
sql: 'INSERT INTO mowers (name, type, brand, model) VALUES (?, ?, ?, ?)',
|
||||||
|
args: ['Electric Mower', 'Electric', 'Generic', 'Electric Model']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Insert sample data if table is empty
|
// Insert sample data if table is empty
|
||||||
const countResult = await db.execute('SELECT COUNT(*) as count FROM zones');
|
const countResult = await db.execute('SELECT COUNT(*) as count FROM zones');
|
||||||
const count = countResult.rows[0].count;
|
const count = countResult.rows[0].count;
|
||||||
@ -89,30 +164,84 @@ if (count === 0) {
|
|||||||
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
const twoWeeksAgo = new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000);
|
const twoWeeksAgo = new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
await db.execute({
|
// Get mower IDs for sample data
|
||||||
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)',
|
const mowersResult = await db.execute('SELECT id FROM mowers LIMIT 2');
|
||||||
args: ['Front Yard', weekAgo.toISOString(), 7, 150.5]
|
const batteryMowerId = mowersResult.rows[0]?.id || 1;
|
||||||
|
const electricMowerId = mowersResult.rows[1]?.id || 2;
|
||||||
|
|
||||||
|
// Insert zones with different schedule types
|
||||||
|
const frontYardResult = await db.execute({
|
||||||
|
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, scheduleType, area) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
args: ['Front Yard', weekAgo.toISOString(), 7, 'interval', 150.5]
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.execute({
|
const backYardResult = await db.execute({
|
||||||
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)',
|
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, scheduleType, area) VALUES (?, ?, ?, ?, ?)',
|
||||||
args: ['Back Yard', twoWeeksAgo.toISOString(), 10, 280.0]
|
args: ['Back Yard', twoWeeksAgo.toISOString(), 10, 'interval', 280.0]
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.execute({
|
const nextWeek = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||||
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)',
|
const sideGardenResult = await db.execute({
|
||||||
args: ['Side Garden', today.toISOString(), 14, 75.25]
|
sql: 'INSERT INTO zones (name, lastMowedDate, nextMowDate, scheduleType, area) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
args: ['Side Garden', today.toISOString(), nextWeek.toISOString(), 'specific', 75.25]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Insert sample mowing history with mower assignments
|
||||||
|
const sampleHistory = [
|
||||||
|
{ zoneId: frontYardResult.lastInsertRowid, mowerId: batteryMowerId, date: today, duration: 45, weather: 'Sunny', notes: 'Perfect mowing conditions today' },
|
||||||
|
{ zoneId: frontYardResult.lastInsertRowid, mowerId: electricMowerId, date: weekAgo, duration: 40, weather: 'Cloudy', notes: 'Grass was a bit wet from morning dew' },
|
||||||
|
{ zoneId: backYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000), duration: 60, weather: 'Partly cloudy', notes: 'Trimmed edges and cleaned up leaves' },
|
||||||
|
{ zoneId: sideGardenResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 3 * 24 * 60 * 60 * 1000), duration: 25, weather: 'Sunny', notes: 'Quick touch-up, looks great' },
|
||||||
|
{ zoneId: frontYardResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000), duration: 50, weather: 'Overcast', notes: 'First mow of the season' },
|
||||||
|
{ zoneId: backYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 16 * 24 * 60 * 60 * 1000), duration: 65, weather: 'Sunny', notes: 'Had to go slow due to thick growth' },
|
||||||
|
{ zoneId: sideGardenResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 17 * 24 * 60 * 60 * 1000), duration: 30, weather: 'Windy', notes: 'Challenging conditions but got it done' },
|
||||||
|
{ zoneId: frontYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 21 * 24 * 60 * 60 * 1000), duration: 42, weather: 'Cool', notes: 'Nice cool morning for mowing' },
|
||||||
|
{ zoneId: backYardResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000), duration: 55, weather: 'Humid', notes: 'Very humid day, took frequent breaks' },
|
||||||
|
{ zoneId: sideGardenResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 31 * 24 * 60 * 60 * 1000), duration: 28, weather: 'Sunny', notes: 'Beautiful day for yard work' },
|
||||||
|
{ zoneId: frontYardResult.lastInsertRowid, mowerId: batteryMowerId, date: new Date(today.getTime() - 35 * 24 * 60 * 60 * 1000), duration: 38, weather: 'Partly cloudy', notes: 'Standard weekly maintenance' },
|
||||||
|
{ zoneId: backYardResult.lastInsertRowid, mowerId: electricMowerId, date: new Date(today.getTime() - 44 * 24 * 60 * 60 * 1000), duration: 70, weather: 'Hot', notes: 'Very hot day, started early morning' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const history of sampleHistory) {
|
||||||
|
await db.execute({
|
||||||
|
sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, duration, weather, notes) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
args: [history.zoneId, history.mowerId, history.date.toISOString(), history.duration, history.weather, history.notes]
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to calculate zone status
|
// Helper function to calculate zone status
|
||||||
function calculateZoneStatus(zone) {
|
function calculateZoneStatus(zone) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
|
||||||
|
// For new zones that haven't been mowed yet
|
||||||
|
if (!zone.lastMowedDate) {
|
||||||
|
return {
|
||||||
|
...zone,
|
||||||
|
daysSinceLastMow: null,
|
||||||
|
daysUntilNext: null,
|
||||||
|
status: 'new',
|
||||||
|
isOverdue: false,
|
||||||
|
isDueToday: false,
|
||||||
|
isNew: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const lastMowed = new Date(zone.lastMowedDate);
|
const lastMowed = new Date(zone.lastMowedDate);
|
||||||
const daysSinceLastMow = Math.floor((today - lastMowed) / (1000 * 60 * 60 * 24));
|
const daysSinceLastMow = Math.floor((today - lastMowed) / (1000 * 60 * 60 * 24));
|
||||||
const daysUntilNext = zone.intervalDays - daysSinceLastMow;
|
|
||||||
|
|
||||||
|
let daysUntilNext;
|
||||||
let status = 'ok';
|
let status = 'ok';
|
||||||
|
|
||||||
|
if (zone.scheduleType === 'specific' && zone.nextMowDate) {
|
||||||
|
// Specific date scheduling
|
||||||
|
const nextMowDate = new Date(zone.nextMowDate);
|
||||||
|
daysUntilNext = Math.floor((nextMowDate - today) / (1000 * 60 * 60 * 24));
|
||||||
|
} else {
|
||||||
|
// Interval-based scheduling
|
||||||
|
daysUntilNext = zone.intervalDays - daysSinceLastMow;
|
||||||
|
}
|
||||||
|
|
||||||
if (daysUntilNext < 0) {
|
if (daysUntilNext < 0) {
|
||||||
status = 'overdue';
|
status = 'overdue';
|
||||||
} else if (daysUntilNext <= 0) {
|
} else if (daysUntilNext <= 0) {
|
||||||
@ -127,10 +256,73 @@ function calculateZoneStatus(zone) {
|
|||||||
daysUntilNext,
|
daysUntilNext,
|
||||||
status,
|
status,
|
||||||
isOverdue: daysUntilNext < 0,
|
isOverdue: daysUntilNext < 0,
|
||||||
isDueToday: daysUntilNext <= 0 && daysUntilNext >= -1
|
isDueToday: daysUntilNext <= 0 && daysUntilNext >= -1,
|
||||||
|
isNew: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to calculate next mow date for interval scheduling
|
||||||
|
function calculateNextMowDate(lastMowedDate, intervalDays) {
|
||||||
|
if (!lastMowedDate || !intervalDays) return null;
|
||||||
|
const lastMowed = new Date(lastMowedDate);
|
||||||
|
const nextMow = new Date(lastMowed.getTime() + (intervalDays * 24 * 60 * 60 * 1000));
|
||||||
|
return nextMow.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to generate session ID
|
||||||
|
function generateSessionId() {
|
||||||
|
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mower routes
|
||||||
|
app.get('/api/mowers', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.execute('SELECT * FROM mowers WHERE isActive = 1 ORDER BY name');
|
||||||
|
const mowers = result.rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
type: row.type,
|
||||||
|
brand: row.brand,
|
||||||
|
model: row.model,
|
||||||
|
isActive: row.isActive,
|
||||||
|
createdAt: row.createdAt
|
||||||
|
}));
|
||||||
|
res.json(mowers);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/mowers', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, type, brand, model } = req.body;
|
||||||
|
|
||||||
|
const result = await db.execute({
|
||||||
|
sql: 'INSERT INTO mowers (name, type, brand, model) VALUES (?, ?, ?, ?)',
|
||||||
|
args: [name, type, brand || null, model || null]
|
||||||
|
});
|
||||||
|
|
||||||
|
const newMowerResult = await db.execute({
|
||||||
|
sql: 'SELECT * FROM mowers WHERE id = ?',
|
||||||
|
args: [result.lastInsertRowid]
|
||||||
|
});
|
||||||
|
|
||||||
|
const newMower = {
|
||||||
|
id: newMowerResult.rows[0].id,
|
||||||
|
name: newMowerResult.rows[0].name,
|
||||||
|
type: newMowerResult.rows[0].type,
|
||||||
|
brand: newMowerResult.rows[0].brand,
|
||||||
|
model: newMowerResult.rows[0].model,
|
||||||
|
isActive: newMowerResult.rows[0].isActive,
|
||||||
|
createdAt: newMowerResult.rows[0].createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(newMower);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.get('/api/zones', async (req, res) => {
|
app.get('/api/zones', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -141,6 +333,8 @@ app.get('/api/zones', async (req, res) => {
|
|||||||
imagePath: row.imagePath,
|
imagePath: row.imagePath,
|
||||||
lastMowedDate: row.lastMowedDate,
|
lastMowedDate: row.lastMowedDate,
|
||||||
intervalDays: row.intervalDays,
|
intervalDays: row.intervalDays,
|
||||||
|
nextMowDate: row.nextMowDate,
|
||||||
|
scheduleType: row.scheduleType || 'interval',
|
||||||
area: row.area || 0,
|
area: row.area || 0,
|
||||||
createdAt: row.createdAt
|
createdAt: row.createdAt
|
||||||
}));
|
}));
|
||||||
@ -168,6 +362,8 @@ app.get('/api/zones/:id', async (req, res) => {
|
|||||||
imagePath: result.rows[0].imagePath,
|
imagePath: result.rows[0].imagePath,
|
||||||
lastMowedDate: result.rows[0].lastMowedDate,
|
lastMowedDate: result.rows[0].lastMowedDate,
|
||||||
intervalDays: result.rows[0].intervalDays,
|
intervalDays: result.rows[0].intervalDays,
|
||||||
|
nextMowDate: result.rows[0].nextMowDate,
|
||||||
|
scheduleType: result.rows[0].scheduleType || 'interval',
|
||||||
area: result.rows[0].area || 0,
|
area: result.rows[0].area || 0,
|
||||||
createdAt: result.rows[0].createdAt
|
createdAt: result.rows[0].createdAt
|
||||||
};
|
};
|
||||||
@ -179,16 +375,213 @@ app.get('/api/zones/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get mowing history for all zones or specific zone with pagination
|
||||||
|
app.get('/api/history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { zoneId, limit = 10, offset = 0 } = req.query;
|
||||||
|
|
||||||
|
// Get total count for pagination
|
||||||
|
let countSql = `
|
||||||
|
SELECT COUNT(*) as total
|
||||||
|
FROM mowing_history mh
|
||||||
|
JOIN zones z ON mh.zoneId = z.id
|
||||||
|
LEFT JOIN mowers m ON mh.mowerId = m.id
|
||||||
|
`;
|
||||||
|
let countArgs = [];
|
||||||
|
|
||||||
|
if (zoneId) {
|
||||||
|
countSql += ' WHERE mh.zoneId = ?';
|
||||||
|
countArgs.push(zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await db.execute({ sql: countSql, args: countArgs });
|
||||||
|
const total = countResult.rows[0].total;
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
let sql = `
|
||||||
|
SELECT
|
||||||
|
mh.*,
|
||||||
|
z.name as zoneName,
|
||||||
|
z.area as zoneArea,
|
||||||
|
m.name as mowerName,
|
||||||
|
m.type as mowerType
|
||||||
|
FROM mowing_history mh
|
||||||
|
JOIN zones z ON mh.zoneId = z.id
|
||||||
|
LEFT JOIN mowers m ON mh.mowerId = m.id
|
||||||
|
`;
|
||||||
|
let args = [];
|
||||||
|
|
||||||
|
if (zoneId) {
|
||||||
|
sql += ' WHERE mh.zoneId = ?';
|
||||||
|
args.push(zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY mh.mowedDate DESC LIMIT ? OFFSET ?';
|
||||||
|
args.push(parseInt(limit), parseInt(offset));
|
||||||
|
|
||||||
|
const result = await db.execute({ sql, args });
|
||||||
|
|
||||||
|
const history = result.rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
zoneId: row.zoneId,
|
||||||
|
zoneName: row.zoneName,
|
||||||
|
zoneArea: row.zoneArea,
|
||||||
|
mowerId: row.mowerId,
|
||||||
|
mowerName: row.mowerName,
|
||||||
|
mowerType: row.mowerType,
|
||||||
|
mowedDate: row.mowedDate,
|
||||||
|
notes: row.notes,
|
||||||
|
duration: row.duration,
|
||||||
|
weather: row.weather,
|
||||||
|
sessionId: row.sessionId,
|
||||||
|
createdAt: row.createdAt
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Calculate pagination info
|
||||||
|
const currentPage = Math.floor(parseInt(offset) / parseInt(limit)) + 1;
|
||||||
|
const totalPages = Math.ceil(total / parseInt(limit));
|
||||||
|
const hasNextPage = currentPage < totalPages;
|
||||||
|
const hasPrevPage = currentPage > 1;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: history,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage,
|
||||||
|
hasPrevPage,
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get mowing statistics
|
||||||
|
app.get('/api/history/stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { period = '30' } = req.query; // days
|
||||||
|
const periodDays = parseInt(period);
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - periodDays);
|
||||||
|
|
||||||
|
// Total mowing sessions
|
||||||
|
const totalResult = await db.execute({
|
||||||
|
sql: `
|
||||||
|
SELECT COUNT(*) as total FROM (
|
||||||
|
SELECT 1 FROM mowing_history
|
||||||
|
WHERE mowedDate >= ? AND sessionId IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT 1 FROM mowing_history
|
||||||
|
WHERE mowedDate >= ? AND sessionId IS NOT NULL
|
||||||
|
GROUP BY sessionId
|
||||||
|
) AS combined
|
||||||
|
`,
|
||||||
|
args: [startDate.toISOString(), startDate.toISOString()]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Total time spent
|
||||||
|
const timeResult = await db.execute({
|
||||||
|
sql: 'SELECT SUM(duration) as totalMinutes FROM mowing_history WHERE mowedDate >= ? AND duration IS NOT NULL',
|
||||||
|
args: [startDate.toISOString()]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Total area mowed
|
||||||
|
const areaResult = await db.execute({
|
||||||
|
sql: `
|
||||||
|
SELECT SUM(z.area) as totalArea
|
||||||
|
FROM mowing_history mh
|
||||||
|
JOIN zones z ON mh.zoneId = z.id
|
||||||
|
WHERE mh.mowedDate >= ?
|
||||||
|
`,
|
||||||
|
args: [startDate.toISOString()]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Most active zone
|
||||||
|
const activeZoneResult = await db.execute({
|
||||||
|
sql: `
|
||||||
|
SELECT z.name, COUNT(*) as sessions
|
||||||
|
FROM mowing_history mh
|
||||||
|
JOIN zones z ON mh.zoneId = z.id
|
||||||
|
WHERE mh.mowedDate >= ?
|
||||||
|
GROUP BY mh.zoneId, z.name
|
||||||
|
ORDER BY sessions DESC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
args: [startDate.toISOString()]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Most used mower
|
||||||
|
const activeMowerResult = await db.execute({
|
||||||
|
sql: `
|
||||||
|
SELECT m.name, m.type, COUNT(*) as sessions
|
||||||
|
FROM mowing_history mh
|
||||||
|
JOIN mowers m ON mh.mowerId = m.id
|
||||||
|
WHERE mh.mowedDate >= ?
|
||||||
|
GROUP BY mh.mowerId, m.name, m.type
|
||||||
|
ORDER BY sessions DESC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
args: [startDate.toISOString()]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Daily activity for chart
|
||||||
|
const dailyResult = await db.execute({
|
||||||
|
sql: `
|
||||||
|
SELECT
|
||||||
|
DATE(mowedDate) as date,
|
||||||
|
COUNT(*) as sessions,
|
||||||
|
SUM(duration) as totalDuration,
|
||||||
|
SUM(z.area) as totalArea
|
||||||
|
FROM mowing_history mh
|
||||||
|
JOIN zones z ON mh.zoneId = z.id
|
||||||
|
WHERE mowedDate >= ?
|
||||||
|
GROUP BY DATE(mowedDate)
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT 30
|
||||||
|
`,
|
||||||
|
args: [startDate.toISOString()]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
period: periodDays,
|
||||||
|
totalSessions: totalResult.rows[0].total,
|
||||||
|
totalMinutes: timeResult.rows[0].totalMinutes || 0,
|
||||||
|
totalArea: areaResult.rows[0].totalArea || 0,
|
||||||
|
mostActiveZone: activeZoneResult.rows[0] || null,
|
||||||
|
mostUsedMower: activeMowerResult.rows[0] || null,
|
||||||
|
dailyActivity: dailyResult.rows.map(row => ({
|
||||||
|
date: row.date,
|
||||||
|
sessions: row.sessions,
|
||||||
|
duration: row.totalDuration || 0,
|
||||||
|
area: row.totalArea || 0
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/zones', upload.single('image'), async (req, res) => {
|
app.post('/api/zones', upload.single('image'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, intervalDays, area } = req.body;
|
const { name, intervalDays, nextMowDate, scheduleType, area } = req.body;
|
||||||
const imagePath = req.file ? `/uploads/${req.file.filename}` : null;
|
const imagePath = req.file ? `/uploads/${req.file.filename}` : null;
|
||||||
const lastMowedDate = new Date().toISOString();
|
|
||||||
|
|
||||||
const result = await db.execute({
|
// For new zones, don't set lastMowedDate (they haven't been mowed yet)
|
||||||
sql: 'INSERT INTO zones (name, imagePath, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?, ?)',
|
let sql, args;
|
||||||
args: [name, imagePath, lastMowedDate, parseInt(intervalDays), parseFloat(area) || 0]
|
|
||||||
});
|
if (scheduleType === 'specific') {
|
||||||
|
sql = 'INSERT INTO zones (name, imagePath, nextMowDate, scheduleType, area) VALUES (?, ?, ?, ?, ?)';
|
||||||
|
args = [name, imagePath, nextMowDate, scheduleType, parseFloat(area) || 0];
|
||||||
|
} else {
|
||||||
|
sql = 'INSERT INTO zones (name, imagePath, intervalDays, scheduleType, area) VALUES (?, ?, ?, ?, ?)';
|
||||||
|
args = [name, imagePath, parseInt(intervalDays), scheduleType || 'interval', parseFloat(area) || 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.execute({ sql, args });
|
||||||
|
|
||||||
const newZoneResult = await db.execute({
|
const newZoneResult = await db.execute({
|
||||||
sql: 'SELECT * FROM zones WHERE id = ?',
|
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||||
@ -201,6 +594,8 @@ app.post('/api/zones', upload.single('image'), async (req, res) => {
|
|||||||
imagePath: newZoneResult.rows[0].imagePath,
|
imagePath: newZoneResult.rows[0].imagePath,
|
||||||
lastMowedDate: newZoneResult.rows[0].lastMowedDate,
|
lastMowedDate: newZoneResult.rows[0].lastMowedDate,
|
||||||
intervalDays: newZoneResult.rows[0].intervalDays,
|
intervalDays: newZoneResult.rows[0].intervalDays,
|
||||||
|
nextMowDate: newZoneResult.rows[0].nextMowDate,
|
||||||
|
scheduleType: newZoneResult.rows[0].scheduleType || 'interval',
|
||||||
area: newZoneResult.rows[0].area || 0,
|
area: newZoneResult.rows[0].area || 0,
|
||||||
createdAt: newZoneResult.rows[0].createdAt
|
createdAt: newZoneResult.rows[0].createdAt
|
||||||
};
|
};
|
||||||
@ -214,7 +609,7 @@ app.post('/api/zones', upload.single('image'), async (req, res) => {
|
|||||||
|
|
||||||
app.put('/api/zones/:id', upload.single('image'), async (req, res) => {
|
app.put('/api/zones/:id', upload.single('image'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, intervalDays, lastMowedDate, area } = req.body;
|
const { name, intervalDays, lastMowedDate, nextMowDate, scheduleType, area } = req.body;
|
||||||
|
|
||||||
const existingResult = await db.execute({
|
const existingResult = await db.execute({
|
||||||
sql: 'SELECT * FROM zones WHERE id = ?',
|
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||||
@ -228,10 +623,17 @@ app.put('/api/zones/:id', upload.single('image'), async (req, res) => {
|
|||||||
const existingZone = existingResult.rows[0];
|
const existingZone = existingResult.rows[0];
|
||||||
const imagePath = req.file ? `/uploads/${req.file.filename}` : existingZone.imagePath;
|
const imagePath = req.file ? `/uploads/${req.file.filename}` : existingZone.imagePath;
|
||||||
|
|
||||||
await db.execute({
|
let sql, args;
|
||||||
sql: 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, intervalDays = ?, area = ? WHERE id = ?',
|
|
||||||
args: [name, imagePath, lastMowedDate, parseInt(intervalDays), parseFloat(area) || 0, req.params.id]
|
if (scheduleType === 'specific') {
|
||||||
});
|
sql = 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, nextMowDate = ?, scheduleType = ?, intervalDays = NULL, area = ? WHERE id = ?';
|
||||||
|
args = [name, imagePath, lastMowedDate || null, nextMowDate, scheduleType, parseFloat(area) || 0, req.params.id];
|
||||||
|
} else {
|
||||||
|
sql = 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, intervalDays = ?, scheduleType = ?, nextMowDate = NULL, area = ? WHERE id = ?';
|
||||||
|
args = [name, imagePath, lastMowedDate || null, parseInt(intervalDays), scheduleType || 'interval', parseFloat(area) || 0, req.params.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute({ sql, args });
|
||||||
|
|
||||||
// Delete old image if new one was provided
|
// Delete old image if new one was provided
|
||||||
if (req.file && existingZone.imagePath) {
|
if (req.file && existingZone.imagePath) {
|
||||||
@ -252,6 +654,8 @@ app.put('/api/zones/:id', upload.single('image'), async (req, res) => {
|
|||||||
imagePath: updatedResult.rows[0].imagePath,
|
imagePath: updatedResult.rows[0].imagePath,
|
||||||
lastMowedDate: updatedResult.rows[0].lastMowedDate,
|
lastMowedDate: updatedResult.rows[0].lastMowedDate,
|
||||||
intervalDays: updatedResult.rows[0].intervalDays,
|
intervalDays: updatedResult.rows[0].intervalDays,
|
||||||
|
nextMowDate: updatedResult.rows[0].nextMowDate,
|
||||||
|
scheduleType: updatedResult.rows[0].scheduleType || 'interval',
|
||||||
area: updatedResult.rows[0].area || 0,
|
area: updatedResult.rows[0].area || 0,
|
||||||
createdAt: updatedResult.rows[0].createdAt
|
createdAt: updatedResult.rows[0].createdAt
|
||||||
};
|
};
|
||||||
@ -297,11 +701,40 @@ app.delete('/api/zones/:id', async (req, res) => {
|
|||||||
|
|
||||||
app.post('/api/zones/:id/mow', async (req, res) => {
|
app.post('/api/zones/:id/mow', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const { notes, duration, weather, mowerId } = req.body;
|
||||||
const today = new Date().toISOString();
|
const today = new Date().toISOString();
|
||||||
|
|
||||||
|
// Get current zone data
|
||||||
|
const zoneResult = await db.execute({
|
||||||
|
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||||
|
args: [req.params.id]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (zoneResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Zone not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const zone = zoneResult.rows[0];
|
||||||
|
|
||||||
|
// Update zone's last mowed date and calculate next mow date if using interval scheduling
|
||||||
|
let sql, args;
|
||||||
|
|
||||||
|
if (zone.scheduleType === 'interval' && zone.intervalDays) {
|
||||||
|
const nextMowDate = calculateNextMowDate(today, zone.intervalDays);
|
||||||
|
sql = 'UPDATE zones SET lastMowedDate = ?, nextMowDate = ? WHERE id = ?';
|
||||||
|
args = [today, nextMowDate, req.params.id];
|
||||||
|
} else {
|
||||||
|
// For specific date scheduling, just update last mowed date
|
||||||
|
sql = 'UPDATE zones SET lastMowedDate = ? WHERE id = ?';
|
||||||
|
args = [today, req.params.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute({ sql, args });
|
||||||
|
|
||||||
|
// Add to mowing history
|
||||||
await db.execute({
|
await db.execute({
|
||||||
sql: 'UPDATE zones SET lastMowedDate = ? WHERE id = ?',
|
sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
args: [today, req.params.id]
|
args: [req.params.id, mowerId || null, today, notes || null, duration || null, weather || null]
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedResult = await db.execute({
|
const updatedResult = await db.execute({
|
||||||
@ -315,6 +748,8 @@ app.post('/api/zones/:id/mow', async (req, res) => {
|
|||||||
imagePath: updatedResult.rows[0].imagePath,
|
imagePath: updatedResult.rows[0].imagePath,
|
||||||
lastMowedDate: updatedResult.rows[0].lastMowedDate,
|
lastMowedDate: updatedResult.rows[0].lastMowedDate,
|
||||||
intervalDays: updatedResult.rows[0].intervalDays,
|
intervalDays: updatedResult.rows[0].intervalDays,
|
||||||
|
nextMowDate: updatedResult.rows[0].nextMowDate,
|
||||||
|
scheduleType: updatedResult.rows[0].scheduleType || 'interval',
|
||||||
area: updatedResult.rows[0].area || 0,
|
area: updatedResult.rows[0].area || 0,
|
||||||
createdAt: updatedResult.rows[0].createdAt
|
createdAt: updatedResult.rows[0].createdAt
|
||||||
};
|
};
|
||||||
@ -326,6 +761,79 @@ app.post('/api/zones/:id/mow', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bulk mowing endpoint
|
||||||
|
app.post('/api/zones/bulk-mow', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { selectedZoneIds, notes, totalDuration, weather, mowerId } = req.body;
|
||||||
|
|
||||||
|
if (!selectedZoneIds || selectedZoneIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No zones selected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date().toISOString();
|
||||||
|
const sessionId = generateSessionId();
|
||||||
|
|
||||||
|
// Get zone data for selected zones
|
||||||
|
const zoneResults = await Promise.all(
|
||||||
|
selectedZoneIds.map(id =>
|
||||||
|
db.execute({
|
||||||
|
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||||
|
args: [id]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const zones = zoneResults.map(result => result.rows[0]).filter(Boolean);
|
||||||
|
|
||||||
|
if (zones.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'No valid zones found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total area for proportional time distribution
|
||||||
|
const totalArea = zones.reduce((sum, zone) => sum + (zone.area || 0), 0);
|
||||||
|
|
||||||
|
// Process each zone
|
||||||
|
for (const zone of zones) {
|
||||||
|
// Calculate proportional duration
|
||||||
|
let zoneDuration = null;
|
||||||
|
if (totalDuration && totalArea > 0 && zone.area > 0) {
|
||||||
|
const proportion = zone.area / totalArea;
|
||||||
|
zoneDuration = Math.round(totalDuration * proportion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update zone's last mowed date and calculate next mow date if using interval scheduling
|
||||||
|
let sql, args;
|
||||||
|
|
||||||
|
if (zone.scheduleType === 'interval' && zone.intervalDays) {
|
||||||
|
const nextMowDate = calculateNextMowDate(today, zone.intervalDays);
|
||||||
|
sql = 'UPDATE zones SET lastMowedDate = ?, nextMowDate = ? WHERE id = ?';
|
||||||
|
args = [today, nextMowDate, zone.id];
|
||||||
|
} else {
|
||||||
|
// For specific date scheduling, just update last mowed date
|
||||||
|
sql = 'UPDATE zones SET lastMowedDate = ? WHERE id = ?';
|
||||||
|
args = [today, zone.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute({ sql, args });
|
||||||
|
|
||||||
|
// Add to mowing history with session ID
|
||||||
|
await db.execute({
|
||||||
|
sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather, sessionId) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
args: [zone.id, mowerId || null, today, notes || null, zoneDuration, weather || null, sessionId]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Successfully recorded mowing session for ${zones.length} zones`,
|
||||||
|
sessionId,
|
||||||
|
zonesUpdated: zones.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Serve React app in production
|
// Serve React app in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
|
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 React, { useState, useEffect } from 'react';
|
||||||
import { Plus, Filter, Calendar, Scissors, AlertTriangle, Square, CheckCircle, Clock, Map } from './Icons';
|
import { Plus, Filter, Calendar, Scissors, AlertTriangle, Square, CheckCircle, Clock, Map, History } from './Icons';
|
||||||
import { Zone } from '../types/zone';
|
import { Zone, MowingFormData, BulkMowingFormData } from '../types/zone';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
import ZoneCard from './ZoneCard';
|
import ZoneCard from './ZoneCard';
|
||||||
import ZoneForm from './ZoneForm';
|
import ZoneForm from './ZoneForm';
|
||||||
import SitePlan from './SitePlan';
|
import SitePlan from './SitePlan';
|
||||||
|
import HistoryView from './HistoryView';
|
||||||
|
import MowingModal from './MowingModal';
|
||||||
|
import BulkMowingModal from "./BulkMowingModal";
|
||||||
|
|
||||||
type FilterType = 'all' | 'due' | 'overdue';
|
type FilterType = 'all' | 'due' | 'overdue' | 'new';
|
||||||
type ViewType = 'dashboard' | 'sitePlan';
|
type ViewType = 'dashboard' | 'sitePlan' | 'history';
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const [zones, setZones] = useState<Zone[]>([]);
|
const [zones, setZones] = useState<Zone[]>([]);
|
||||||
@ -17,7 +20,11 @@ const Dashboard: React.FC = () => {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingZone, setEditingZone] = useState<Zone | null>(null);
|
const [editingZone, setEditingZone] = useState<Zone | null>(null);
|
||||||
const [selectedZoneId, setSelectedZoneId] = useState<number | undefined>();
|
const [selectedZoneId, setSelectedZoneId] = useState<number | undefined>();
|
||||||
|
const [showMowingModal, setShowMowingModal] = useState(false);
|
||||||
|
const [showBulkMowingModal, setShowBulkMowingModal] = useState(false);
|
||||||
|
const [mowingZone, setMowingZone] = useState<Zone | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [mowingLoading, setMowingLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadZones();
|
loadZones();
|
||||||
@ -47,18 +54,46 @@ const Dashboard: React.FC = () => {
|
|||||||
case 'overdue':
|
case 'overdue':
|
||||||
filtered = zones.filter(zone => zone.isOverdue);
|
filtered = zones.filter(zone => zone.isOverdue);
|
||||||
break;
|
break;
|
||||||
|
case 'new':
|
||||||
|
filtered = zones.filter(zone => zone.isNew);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
filtered = zones;
|
filtered = zones;
|
||||||
}
|
}
|
||||||
setFilteredZones(filtered);
|
setFilteredZones(filtered);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAsMowed = async (id: number) => {
|
const handleMarkAsMowed = (zone: Zone) => {
|
||||||
|
setMowingZone(zone);
|
||||||
|
setShowMowingModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMowingSubmit = async (data: MowingFormData) => {
|
||||||
|
if (!mowingZone) return;
|
||||||
|
|
||||||
|
setMowingLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.markAsMowed(id);
|
await api.markAsMowed(mowingZone.id, data);
|
||||||
|
setShowMowingModal(false);
|
||||||
|
setMowingZone(null);
|
||||||
loadZones();
|
loadZones();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to mark as mowed:', error);
|
console.error('Failed to mark as mowed:', error);
|
||||||
|
} finally {
|
||||||
|
setMowingLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkMowingSubmit = async (data: BulkMowingFormData) => {
|
||||||
|
setMowingLoading(true);
|
||||||
|
try {
|
||||||
|
await api.bulkMarkAsMowed(data);
|
||||||
|
setShowBulkMowingModal(false);
|
||||||
|
loadZones();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to record bulk mowing session:', error);
|
||||||
|
} finally {
|
||||||
|
setMowingLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -97,6 +132,7 @@ const Dashboard: React.FC = () => {
|
|||||||
// Calculate area statistics
|
// Calculate area statistics
|
||||||
const overdueCount = zones.filter(zone => zone.isOverdue).length;
|
const overdueCount = zones.filter(zone => zone.isOverdue).length;
|
||||||
const dueCount = zones.filter(zone => zone.isDueToday).length;
|
const dueCount = zones.filter(zone => zone.isDueToday).length;
|
||||||
|
const newCount = zones.filter(zone => zone.isNew).length;
|
||||||
const okCount = zones.filter(zone => zone.status === 'ok').length;
|
const okCount = zones.filter(zone => zone.status === 'ok').length;
|
||||||
const totalArea = zones.reduce((sum, zone) => sum + zone.area, 0);
|
const totalArea = zones.reduce((sum, zone) => sum + zone.area, 0);
|
||||||
|
|
||||||
@ -105,12 +141,13 @@ const Dashboard: React.FC = () => {
|
|||||||
.filter(zone => zone.status === 'ok')
|
.filter(zone => zone.status === 'ok')
|
||||||
.reduce((sum, zone) => sum + zone.area, 0);
|
.reduce((sum, zone) => sum + zone.area, 0);
|
||||||
|
|
||||||
const remainingArea = zones
|
|
||||||
.filter(zone => zone.status === 'due' || zone.status === 'overdue')
|
|
||||||
.reduce((sum, zone) => sum + zone.area, 0);
|
|
||||||
|
|
||||||
const mowedPercentage = totalArea > 0 ? (mowedArea / totalArea) * 100 : 0;
|
const mowedPercentage = totalArea > 0 ? (mowedArea / totalArea) * 100 : 0;
|
||||||
|
|
||||||
|
// Check if there are zones that need mowing for bulk action
|
||||||
|
const zonesNeedingMowing = zones.filter(zone =>
|
||||||
|
zone.status === 'due' || zone.status === 'overdue' || zone.status === 'new'
|
||||||
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
@ -157,6 +194,17 @@ const Dashboard: React.FC = () => {
|
|||||||
<Map className="h-4 w-4 inline mr-2" />
|
<Map className="h-4 w-4 inline mr-2" />
|
||||||
План участка
|
План участка
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('history')}
|
||||||
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
|
||||||
|
view === 'history'
|
||||||
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<History className="h-4 w-4 inline mr-2" />
|
||||||
|
История
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -170,7 +218,9 @@ const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === 'sitePlan' ? (
|
{view === 'history' ? (
|
||||||
|
<HistoryView zones={zones} />
|
||||||
|
) : view === 'sitePlan' ? (
|
||||||
<SitePlan
|
<SitePlan
|
||||||
zones={zones}
|
zones={zones}
|
||||||
onZoneSelect={handleZoneSelect}
|
onZoneSelect={handleZoneSelect}
|
||||||
@ -183,7 +233,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-green-500">
|
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-green-500">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">Количество зон</p>
|
<p className="text-sm font-medium text-gray-600">Кол-во зон</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">{zones.length}</p>
|
<p className="text-2xl font-bold text-gray-900">{zones.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<Calendar className="h-8 w-8 text-green-500" />
|
<Calendar className="h-8 w-8 text-green-500" />
|
||||||
@ -193,7 +243,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-purple-500">
|
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-purple-500">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">Общая площадь</p>
|
<p className="text-sm font-medium text-gray-600">Площадь, вся</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">{totalArea.toLocaleString()}</p>
|
<p className="text-2xl font-bold text-gray-900">{totalArea.toLocaleString()}</p>
|
||||||
<p className="text-xs text-gray-500">м2</p>
|
<p className="text-xs text-gray-500">м2</p>
|
||||||
</div>
|
</div>
|
||||||
@ -204,20 +254,33 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-emerald-500">
|
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-emerald-500">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">Скошенная площадь</p>
|
<p className="text-sm font-medium text-gray-600">Актуально</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">{mowedArea.toLocaleString()}</p>
|
<p className="text-2xl font-bold text-gray-900">{okCount}</p>
|
||||||
<p className="text-xs text-gray-500">м2 ({mowedPercentage.toFixed(1)}%)</p>
|
<p className="text-xs text-gray-500">обслужено зон</p>
|
||||||
</div>
|
</div>
|
||||||
<CheckCircle className="h-8 w-8 text-emerald-500" />
|
<CheckCircle className="h-8 w-8 text-emerald-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-blue-500">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Новые зоны</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{newCount}</p>
|
||||||
|
<p className="text-xs text-gray-500">еще не косились</p>
|
||||||
|
</div>
|
||||||
|
<Calendar className="h-8 w-8 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
*/}
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-amber-500">
|
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-amber-500">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">Нужно скосить</p>
|
<p className="text-sm font-medium text-gray-600">Внимание!</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">{remainingArea.toLocaleString()}</p>
|
<p className="text-2xl font-bold text-gray-900">{dueCount + overdueCount}</p>
|
||||||
<p className="text-xs text-gray-500">м2 ({(100 - mowedPercentage).toFixed(1)}%)</p>
|
<p className="text-xs text-gray-500">зон для покоса</p>
|
||||||
</div>
|
</div>
|
||||||
<Clock className="h-8 w-8 text-amber-500" />
|
<Clock className="h-8 w-8 text-amber-500" />
|
||||||
</div>
|
</div>
|
||||||
@ -262,16 +325,41 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="flex justify-between text-sm text-gray-600">
|
<div className="flex justify-between text-sm text-gray-600">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<CheckCircle className="h-4 w-4 text-emerald-500" />
|
<CheckCircle className="h-4 w-4 text-emerald-500" />
|
||||||
{mowedPercentage.toFixed(1)}% Завершено
|
{mowedPercentage.toFixed(1)}% Завершено ({okCount} зон)
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-4 w-4 text-amber-500" />
|
<Clock className="h-4 w-4 text-amber-500" />
|
||||||
{(100 - mowedPercentage).toFixed(1)}% Осталось
|
{(100 - mowedPercentage).toFixed(1)}% Осталось ({dueCount + overdueCount} зон)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bulk Mowing Notice */}
|
||||||
|
{zonesNeedingMowing.length > 1 && (
|
||||||
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Scissors className="h-5 w-5 text-orange-600" />
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-orange-800">
|
||||||
|
Необходимо скосить несколько зон
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-orange-700">
|
||||||
|
Используйте массовое скашивание для записи нескольких зон, скошенных за один сеанс, с пропорциональным распределением времени
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBulkMowingModal(true)}
|
||||||
|
className="bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Массовый покос ({zonesNeedingMowing.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filter Buttons */}
|
{/* Filter Buttons */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -280,6 +368,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{[
|
{[
|
||||||
{ key: 'all' as FilterType, label: 'Все зоны', count: zones.length },
|
{ key: 'all' as FilterType, label: 'Все зоны', count: zones.length },
|
||||||
|
{ key: 'new' as FilterType, label: 'Новые зоны', count: newCount },
|
||||||
{ key: 'due' as FilterType, label: 'Срок - сегодня', count: dueCount },
|
{ key: 'due' as FilterType, label: 'Срок - сегодня', count: dueCount },
|
||||||
{ key: 'overdue' as FilterType, label: 'Срок прошел', count: overdueCount },
|
{ key: 'overdue' as FilterType, label: 'Срок прошел', count: overdueCount },
|
||||||
].map(({ key, label, count }) => (
|
].map(({ key, label, count }) => (
|
||||||
@ -303,9 +392,9 @@ const Dashboard: React.FC = () => {
|
|||||||
{filteredZones.length === 0 ? (
|
{filteredZones.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<Scissors className="mx-auto h-12 w-12 text-gray-400" />
|
<Scissors className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No zones found</h3>
|
<h3 className="mt-2 text-sm font-medium text-gray-900">Не найдено ни одной зоны</h3>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
{filter === 'all' ? 'Get started by adding your first lawn zone.' : `No zones match the "${filter}" filter.`}
|
{filter === 'all' ? 'Get started by adding your first lawn zone.' : `Нет подходящих зон для "${filter}" фильтра.`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -347,6 +436,30 @@ const Dashboard: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Single Zone Mowing Modal */}
|
||||||
|
{showMowingModal && mowingZone && (
|
||||||
|
<MowingModal
|
||||||
|
zoneName={mowingZone.name}
|
||||||
|
onSubmit={handleMowingSubmit}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowMowingModal(false);
|
||||||
|
setMowingZone(null);
|
||||||
|
}}
|
||||||
|
loading={mowingLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bulk Mowing Modal */}
|
||||||
|
{showBulkMowingModal && (
|
||||||
|
<BulkMowingModal
|
||||||
|
zones={zones}
|
||||||
|
onSubmit={handleBulkMowingSubmit}
|
||||||
|
onCancel={() => setShowBulkMowingModal(false)}
|
||||||
|
loading={mowingLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
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
|
// Custom SVG icons to replace Lucide React
|
||||||
export const Plus: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const Plus: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Filter: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const Filter: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Calendar: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const Calendar: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Scissors: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const Scissors: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<circle cx="6" cy="6" r="3" />
|
<circle cx="6" cy="6" r="3" />
|
||||||
<circle cx="6" cy="18" r="3" />
|
<circle cx="6" cy="18" r="3" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m21 12-7-7m7 7-7 7m7-7H8" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m21 12-7-7m7 7-7 7m7-7H8" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const AlertTriangle: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const AlertTriangle: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Square: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const Square: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const CheckCircle: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const CheckCircle: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Clock: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const Clock: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<polyline points="12,6 12,12 16,14" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
<polyline points="12,6 12,12 16,14" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Map: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const Map: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<polygon points="1,6 1,22 8,18 16,22 23,18 23,2 16,6 8,2" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
<polygon points="1,6 1,22 8,18 16,22 23,18 23,2 16,6 8,2" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
<line x1="8" y1="2" x2="8" y2="18" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
<line x1="8" y1="2" x2="8" y2="18" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
<line x1="16" y1="6" x2="16" y2="22" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
<line x1="16" y1="6" x2="16" y2="22" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Edit: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const Edit: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Trash2: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const Trash2: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<polyline points="3,6 5,6 21,6" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
<polyline points="3,6 5,6 21,6" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m19,6v14a2,2 0 0,1-2,2H7a2,2 0 0,1-2-2V6m3,0V4a2,2 0 0,1,2-2h4a2,2 0 0,1,2,2v2" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m19,6v14a2,2 0 0,1-2,2H7a2,2 0 0,1-2-2V6m3,0V4a2,2 0 0,1,2-2h4a2,2 0 0,1,2,2v2" />
|
||||||
<line x1="10" y1="11" x2="10" y2="17" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
<line x1="10" y1="11" x2="10" y2="17" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
<line x1="14" y1="11" x2="14" y2="17" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
<line x1="14" y1="11" x2="14" y2="17" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Camera: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const Camera: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2v11z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2v11z" />
|
||||||
<circle cx="12" cy="13" r="4" />
|
<circle cx="12" cy="13" r="4" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const X: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const X: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<line x1="18" y1="6" x2="6" y2="18" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
<line x1="18" y1="6" x2="6" y2="18" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
<line x1="6" y1="6" x2="18" y2="18" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Upload: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const Upload: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const MapPin: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const MapPin: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
|
||||||
<circle cx="12" cy="10" r="3" />
|
<circle cx="12" cy="10" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Eye: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const Eye: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const EyeOff: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const EyeOff: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24" />
|
||||||
<line x1="1" y1="1" x2="23" y2="23" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
<line x1="1" y1="1" x2="23" y2="23" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
</svg>
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const History: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const BarChart: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<line x1="12" y1="20" x2="12" y2="10" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
|
<line x1="18" y1="20" x2="18" y2="4" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
|
<line x1="6" y1="20" x2="6" y2="16" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FileText: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
|
<polyline points="10,9 9,9 8,9" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Timer: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="13" r="8" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v4l2 2" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 1h6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Cloud: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 10h-1.26A8 8 0 109 20h9a5 5 0 000-10z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChevronDown: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<polyline points="6,9 12,15 18,9" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChevronUp: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<polyline points="18,15 12,9 6,15" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChevronLeft: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<polyline points="15,18 9,12 15,6" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChevronRight: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<polyline points="9,18 15,12 9,6" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Users: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M23 21v-2a4 4 0 00-3-3.87" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 3.13a4 4 0 010 7.75" />
|
||||||
|
</svg>
|
||||||
);
|
);
|
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" />
|
<MapPin className="h-6 w-6 text-blue-600" />
|
||||||
<h3 className="text-lg font-semibold text-gray-900">План участка</h3>
|
<h3 className="text-lg font-semibold text-gray-900">План участка</h3>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
({zoneMarkers.length} of {zones.length} zones marked)
|
({zoneMarkers.length} из {zones.length} зон отмечено)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -151,7 +151,7 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
|
|||||||
? 'bg-blue-100 text-blue-600'
|
? 'bg-blue-100 text-blue-600'
|
||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
title={showMarkers ? 'Hide markers' : 'Show markers'}
|
title={showMarkers ? 'Скрыть маркеры' : 'Показать маркеры'}
|
||||||
>
|
>
|
||||||
{showMarkers ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
{showMarkers ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
@ -164,20 +164,20 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
|
|||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isEditMode ? 'Done Editing' : 'Edit Markers'}
|
{isEditMode ? 'Редактирование завершено' : 'Редактирование Маркеров'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="px-3 py-2 bg-blue-100 text-blue-700 rounded-md text-sm font-medium hover:bg-blue-200 transition-colors duration-200"
|
className="px-3 py-2 bg-blue-100 text-blue-700 rounded-md text-sm font-medium hover:bg-blue-200 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
Change Image
|
Сменить изображение
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={clearSitePlan}
|
onClick={clearSitePlan}
|
||||||
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors duration-200"
|
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors duration-200"
|
||||||
title="Remove site plan"
|
title="Удалить план участка"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@ -187,8 +187,8 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
|
|||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
<div className="mt-3 p-3 bg-blue-50 rounded-md">
|
<div className="mt-3 p-3 bg-blue-50 rounded-md">
|
||||||
<p className="text-sm text-blue-800">
|
<p className="text-sm text-blue-800">
|
||||||
<strong>Edit Mode:</strong> Click on the image to place markers for zones.
|
<strong>Режим редактора:</strong> Нажмите на изображение, чтобы разместить маркеры для зон.
|
||||||
Zones without markers: {zones.filter(zone => !zoneMarkers.some(marker => marker.id === zone.id)).map(zone => zone.name).join(', ') || 'None'}
|
Зоны без маркеров: {zones.filter(zone => !zoneMarkers.some(marker => marker.id === zone.id)).map(zone => zone.name).join(', ') || 'Нет'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -272,11 +272,11 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
|
|||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
{showMarkers && zoneMarkers.length > 0 && (
|
{showMarkers && zoneMarkers.length > 0 && (
|
||||||
<div className="p-4 border-t bg-gray-50">
|
<div className="p-4 border-t bg-gray-50">
|
||||||
<h4 className="text-sm font-medium text-gray-900 mb-3">Zone Status Legend</h4>
|
<h4 className="text-sm font-medium text-gray-900 mb-3">Условные обозначения статуса зон:</h4>
|
||||||
<div className="flex flex-wrap gap-4 text-sm">
|
<div className="flex flex-wrap gap-4 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded-full bg-green-500 border border-green-600"></div>
|
<div className="w-3 h-3 rounded-full bg-green-500 border border-green-600"></div>
|
||||||
<span className="text-gray-700">Up to date</span>
|
<span className="text-gray-700">Актуально</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded-full bg-orange-500 border border-orange-600"></div>
|
<div className="w-3 h-3 rounded-full bg-orange-500 border border-orange-600"></div>
|
||||||
@ -284,7 +284,7 @@ const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded-full bg-red-500 border border-red-600"></div>
|
<div className="w-3 h-3 rounded-full bg-red-500 border border-red-600"></div>
|
||||||
<span className="text-gray-700">Overdue</span>
|
<span className="text-gray-700">Просрочено</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Scissors, Edit, Trash2, Calendar, Camera, Square } from './Icons';
|
import { Scissors, Edit, Trash2, Calendar, Camera, Square, Clock, AlertTriangle } from './Icons';
|
||||||
import { Zone } from '../types/zone';
|
import { Zone } from '../types/zone';
|
||||||
|
|
||||||
interface ZoneCardProps {
|
interface ZoneCardProps {
|
||||||
zone: Zone;
|
zone: Zone;
|
||||||
onMarkAsMowed: (id: number) => void;
|
onMarkAsMowed: (zone: Zone) => void;
|
||||||
onEdit: (zone: Zone) => void;
|
onEdit: (zone: Zone) => void;
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
}
|
}
|
||||||
@ -21,14 +21,18 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
|||||||
return 'border-red-500 bg-red-50';
|
return 'border-red-500 bg-red-50';
|
||||||
case 'due':
|
case 'due':
|
||||||
return 'border-orange-500 bg-orange-50';
|
return 'border-orange-500 bg-orange-50';
|
||||||
|
case 'new':
|
||||||
|
return 'border-blue-500 bg-blue-50';
|
||||||
default:
|
default:
|
||||||
return 'border-green-500 bg-green-50';
|
return 'border-green-500 bg-green-50';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusText = (zone: Zone) => {
|
const getStatusText = (zone: Zone) => {
|
||||||
if (zone.isOverdue) {
|
if (zone.isNew) {
|
||||||
return `${Math.abs(zone.daysUntilNext)} дней посроченно`;
|
return 'Еще не косилась';
|
||||||
|
} else if (zone.isOverdue) {
|
||||||
|
return `${Math.abs(zone.daysUntilNext!)} дней посроченно`;
|
||||||
} else if (zone.isDueToday) {
|
} else if (zone.isDueToday) {
|
||||||
return 'Срок - сегодня';
|
return 'Срок - сегодня';
|
||||||
} else {
|
} else {
|
||||||
@ -42,14 +46,30 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
|||||||
return 'text-red-700';
|
return 'text-red-700';
|
||||||
case 'due':
|
case 'due':
|
||||||
return 'text-orange-700';
|
return 'text-orange-700';
|
||||||
|
case 'new':
|
||||||
|
return 'text-blue-700';
|
||||||
default:
|
default:
|
||||||
return 'text-green-700';
|
return 'text-green-700';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'overdue':
|
||||||
|
return <AlertTriangle className="h-4 w-4 text-red-600" />;
|
||||||
|
case 'due':
|
||||||
|
return <Clock className="h-4 w-4 text-orange-600" />;
|
||||||
|
case 'new':
|
||||||
|
return <Calendar className="h-4 w-4 text-blue-600" />;
|
||||||
|
default:
|
||||||
|
return <Scissors className="h-4 w-4 text-green-600" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
if (!dateString) return 'Никогда';
|
||||||
month: 'short',
|
return new Date(dateString).toLocaleDateString('ru-RU', {
|
||||||
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
});
|
});
|
||||||
@ -60,6 +80,17 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
|||||||
return `${area.toLocaleString()} м2`;
|
return `${area.toLocaleString()} м2`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getScheduleText = (zone: Zone) => {
|
||||||
|
if (zone.scheduleType === 'specific') {
|
||||||
|
if (zone.nextMowDate) {
|
||||||
|
return `Next: ${formatDate(zone.nextMowDate)}`;
|
||||||
|
}
|
||||||
|
return 'Specific date scheduling';
|
||||||
|
} else {
|
||||||
|
return zone.intervalDays ? `Каждые ${zone.intervalDays} дней` : 'Не запланированно';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`bg-white rounded-lg shadow-md border-l-4 ${getStatusColor(zone.status)} hover:shadow-lg transition-shadow duration-200`}>
|
<div className={`bg-white rounded-lg shadow-md border-l-4 ${getStatusColor(zone.status)} hover:shadow-lg transition-shadow duration-200`}>
|
||||||
{/* Zone Image */}
|
{/* Zone Image */}
|
||||||
@ -76,7 +107,7 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
|||||||
<div className="h-48 bg-gray-100 rounded-t-lg flex items-center justify-center">
|
<div className="h-48 bg-gray-100 rounded-t-lg flex items-center justify-center">
|
||||||
<div className="text-center text-gray-400">
|
<div className="text-center text-gray-400">
|
||||||
<Camera className="h-12 w-12 mx-auto mb-2" />
|
<Camera className="h-12 w-12 mx-auto mb-2" />
|
||||||
<p className="text-sm">No image</p>
|
<p className="text-sm">Нет изображения</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -85,8 +116,9 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
|||||||
{/* Zone Name and Status */}
|
{/* Zone Name and Status */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{zone.name}</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">{zone.name}</h3>
|
||||||
<div className={`text-sm font-medium ${getStatusTextColor(zone.status)}`}>
|
<div className={`flex items-center gap-2 text-sm font-medium ${getStatusTextColor(zone.status)}`}>
|
||||||
{getStatusText(zone)}
|
{getStatusIcon(zone.status)}
|
||||||
|
<span>{getStatusText(zone)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -94,11 +126,11 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
|||||||
<div className="space-y-2 mb-4 text-sm text-gray-600">
|
<div className="space-y-2 mb-4 text-sm text-gray-600">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-4 w-4" />
|
||||||
<span>Было скошенно: {formatDate(zone.lastMowedDate)}</span>
|
<span>Была скошена: {zone.lastMowedDate ? formatDate(zone.lastMowedDate) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Scissors className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
<span>Каждые {zone.intervalDays} дней</span>
|
<span>{getScheduleText(zone)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Square className="h-4 w-4" />
|
<Square className="h-4 w-4" />
|
||||||
@ -109,16 +141,18 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
|||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onMarkAsMowed(zone.id)}
|
onClick={() => onMarkAsMowed(zone)}
|
||||||
disabled={zone.status === 'ok' && zone.daysUntilNext > 1}
|
disabled={zone.status === 'ok' && zone.daysUntilNext! > 1}
|
||||||
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors duration-200 ${
|
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors duration-200 ${
|
||||||
zone.status === 'ok' && zone.daysUntilNext > 1
|
zone.status === 'ok' && zone.daysUntilNext! > 1
|
||||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
|
: zone.isNew
|
||||||
|
? 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm hover:shadow-md'
|
||||||
: 'bg-green-600 hover:bg-green-700 text-white shadow-sm hover:shadow-md'
|
: 'bg-green-600 hover:bg-green-700 text-white shadow-sm hover:shadow-md'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Scissors className="h-4 w-4 inline mr-1" />
|
<Scissors className="h-4 w-4 inline mr-1" />
|
||||||
Скошенно
|
{zone.isNew ? 'Первый покос' : 'Скошенно'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { X, Upload, Camera } from './Icons';
|
import { X, Upload, Calendar, Clock } from './Icons';
|
||||||
import { Zone, ZoneFormData } from '../types/zone';
|
import { Zone, ZoneFormData } from '../types/zone';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
|
|
||||||
@ -13,7 +13,9 @@ const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
|
|||||||
const [formData, setFormData] = useState<ZoneFormData>({
|
const [formData, setFormData] = useState<ZoneFormData>({
|
||||||
name: zone?.name || '',
|
name: zone?.name || '',
|
||||||
intervalDays: zone?.intervalDays || 7,
|
intervalDays: zone?.intervalDays || 7,
|
||||||
lastMowedDate: zone ? zone.lastMowedDate.split('T')[0] : new Date().toISOString().split('T')[0],
|
lastMowedDate: zone?.lastMowedDate ? zone.lastMowedDate.split('T')[0] : '',
|
||||||
|
nextMowDate: zone?.nextMowDate ? zone.nextMowDate.split('T')[0] : '',
|
||||||
|
scheduleType: zone?.scheduleType || 'interval',
|
||||||
area: zone?.area || 0,
|
area: zone?.area || 0,
|
||||||
});
|
});
|
||||||
const [imagePreview, setImagePreview] = useState<string | null>(
|
const [imagePreview, setImagePreview] = useState<string | null>(
|
||||||
@ -36,11 +38,33 @@ const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleScheduleTypeChange = (scheduleType: 'interval' | 'specific') => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
scheduleType,
|
||||||
|
// Clear the other scheduling field when switching types
|
||||||
|
...(scheduleType === 'interval' ? { nextMowDate: '' } : { intervalDays: undefined })
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (formData.scheduleType === 'interval' && (!formData.intervalDays || formData.intervalDays < 1)) {
|
||||||
|
setError('Please specify a valid mowing interval (1+ days)');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.scheduleType === 'specific' && !formData.nextMowDate) {
|
||||||
|
setError('Please specify the next mowing date');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (zone) {
|
if (zone) {
|
||||||
await api.updateZone(zone.id, formData);
|
await api.updateZone(zone.id, formData);
|
||||||
@ -60,7 +84,7 @@ const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
|
|||||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between p-6 border-b">
|
<div className="flex items-center justify-between p-6 border-b">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
{zone ? 'Редактирование Зоны' : 'Добавление новой Зоны'}
|
{zone ? 'Редактирование Зоны' : 'Создание новой Зоны'}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
@ -106,42 +130,111 @@ const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
|
|||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
placeholder="e.g., 150.5"
|
placeholder="напр., 150.5"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">Дополнительно - помогает отслеживать общую площадь газона</p>
|
<p className="text-xs text-gray-500 mt-1">Дополнительно - помогает отслеживать общую площадь газона</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mowing Interval */}
|
{/* Schedule Type */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="intervalDays" className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
Интервал покоса (дни)
|
Распасание покоса
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="space-y-3">
|
||||||
type="number"
|
<label className="flex items-center">
|
||||||
id="intervalDays"
|
<input
|
||||||
value={formData.intervalDays}
|
type="radio"
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, intervalDays: parseInt(e.target.value) }))}
|
name="scheduleType"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
value="interval"
|
||||||
required
|
checked={formData.scheduleType === 'interval'}
|
||||||
min="1"
|
onChange={(e) => handleScheduleTypeChange(e.target.value as 'interval')}
|
||||||
max="365"
|
className="mr-3 text-green-600 focus:ring-green-500"
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-gray-500" />
|
||||||
|
<span className="text-sm text-gray-700">Регулярный интервал (каждые X дней)</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="scheduleType"
|
||||||
|
value="specific"
|
||||||
|
checked={formData.scheduleType === 'specific'}
|
||||||
|
onChange={(e) => handleScheduleTypeChange(e.target.value as 'specific')}
|
||||||
|
className="mr-3 text-green-600 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-gray-500" />
|
||||||
|
<span className="text-sm text-gray-700">Определенная дата</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Last Mowed Date */}
|
{/* Conditional Schedule Fields */}
|
||||||
<div>
|
{formData.scheduleType === 'interval' ? (
|
||||||
<label htmlFor="lastMowedDate" className="block text-sm font-medium text-gray-700 mb-2">
|
<div>
|
||||||
Дата последнего покоса
|
<label htmlFor="intervalDays" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
</label>
|
Интервал покоса (дни)
|
||||||
<input
|
</label>
|
||||||
type="date"
|
<input
|
||||||
id="lastMowedDate"
|
type="number"
|
||||||
value={formData.lastMowedDate}
|
id="intervalDays"
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, lastMowedDate: e.target.value }))}
|
value={formData.intervalDays || ''}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
onChange={(e) => setFormData(prev => ({ ...prev, intervalDays: parseInt(e.target.value) || undefined }))}
|
||||||
required
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
/>
|
required
|
||||||
</div>
|
min="1"
|
||||||
|
max="365"
|
||||||
|
placeholder="напр., 7"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Как часто следует косить эту зону?</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="nextMowDate" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Дата следующего покоса
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="nextMowDate"
|
||||||
|
value={formData.nextMowDate || ''}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, nextMowDate: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
|
required
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">When should this zone be mowed next?</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Last Mowed Date (only for editing existing zones) */}
|
||||||
|
{zone && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="lastMowedDate" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Дата последнего покоса
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="lastMowedDate"
|
||||||
|
value={formData.lastMowedDate || ''}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, lastMowedDate: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Оставьте пустым, если эта зона еще не была скошена</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New Zone Notice */}
|
||||||
|
{!zone && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-md p-3">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
<strong>Новая зона:</strong> Эта зона будет помечена как "еще не скошенная" до тех пор, пока вы не запишете первый выход на покос.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Zone Image */}
|
{/* Zone Image */}
|
||||||
<div>
|
<div>
|
||||||
@ -204,7 +297,7 @@ const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
className="flex-1 py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||||
>
|
>
|
||||||
{loading ? 'Сохраняется...' : zone ? 'Изменение Зоны' : 'Создание Зоны'}
|
{loading ? 'Сохраняется...' : zone ? 'Изменить Зону' : 'Создать Зону'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Zone, ZoneFormData } from '../types/zone';
|
import { Zone, ZoneFormData, MowingHistory, MowingHistoryResponse, MowingStats, MowingFormData, Mower, MowerFormData, BulkMowingFormData } from '../types/zone';
|
||||||
|
|
||||||
const API_BASE = 'http://localhost:3001/api';
|
const API_BASE = 'http://localhost:3001/api';
|
||||||
|
|
||||||
@ -18,8 +18,15 @@ export const api = {
|
|||||||
async createZone(data: ZoneFormData): Promise<Zone> {
|
async createZone(data: ZoneFormData): Promise<Zone> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('name', data.name);
|
formData.append('name', data.name);
|
||||||
formData.append('intervalDays', data.intervalDays.toString());
|
|
||||||
formData.append('area', data.area.toString());
|
formData.append('area', data.area.toString());
|
||||||
|
formData.append('scheduleType', data.scheduleType);
|
||||||
|
|
||||||
|
if (data.scheduleType === 'interval' && data.intervalDays) {
|
||||||
|
formData.append('intervalDays', data.intervalDays.toString());
|
||||||
|
} else if (data.scheduleType === 'specific' && data.nextMowDate) {
|
||||||
|
formData.append('nextMowDate', data.nextMowDate);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.image) {
|
if (data.image) {
|
||||||
formData.append('image', data.image);
|
formData.append('image', data.image);
|
||||||
}
|
}
|
||||||
@ -35,9 +42,19 @@ export const api = {
|
|||||||
async updateZone(id: number, data: ZoneFormData): Promise<Zone> {
|
async updateZone(id: number, data: ZoneFormData): Promise<Zone> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('name', data.name);
|
formData.append('name', data.name);
|
||||||
formData.append('intervalDays', data.intervalDays.toString());
|
|
||||||
formData.append('lastMowedDate', data.lastMowedDate);
|
|
||||||
formData.append('area', data.area.toString());
|
formData.append('area', data.area.toString());
|
||||||
|
formData.append('scheduleType', data.scheduleType);
|
||||||
|
|
||||||
|
if (data.lastMowedDate) {
|
||||||
|
formData.append('lastMowedDate', data.lastMowedDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.scheduleType === 'interval' && data.intervalDays) {
|
||||||
|
formData.append('intervalDays', data.intervalDays.toString());
|
||||||
|
} else if (data.scheduleType === 'specific' && data.nextMowDate) {
|
||||||
|
formData.append('nextMowDate', data.nextMowDate);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.image) {
|
if (data.image) {
|
||||||
formData.append('image', data.image);
|
formData.append('image', data.image);
|
||||||
}
|
}
|
||||||
@ -57,11 +74,64 @@ export const api = {
|
|||||||
if (!response.ok) throw new Error('Failed to delete zone');
|
if (!response.ok) throw new Error('Failed to delete zone');
|
||||||
},
|
},
|
||||||
|
|
||||||
async markAsMowed(id: number): Promise<Zone> {
|
async markAsMowed(id: number, data?: MowingFormData): Promise<Zone> {
|
||||||
const response = await fetch(`${API_BASE}/zones/${id}/mow`, {
|
const response = await fetch(`${API_BASE}/zones/${id}/mow`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data || {}),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to mark as mowed');
|
if (!response.ok) throw new Error('Failed to mark as mowed');
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async bulkMarkAsMowed(data: BulkMowingFormData): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE}/zones/bulk-mow`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to record bulk mowing session');
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMowingHistory(zoneId?: number, limit = 10, offset = 0): Promise<MowingHistoryResponse> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: limit.toString(),
|
||||||
|
offset: offset.toString(),
|
||||||
|
});
|
||||||
|
if (zoneId) {
|
||||||
|
params.append('zoneId', zoneId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/history?${params}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch mowing history');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMowingStats(period = 30): Promise<MowingStats> {
|
||||||
|
const response = await fetch(`${API_BASE}/history/stats?period=${period}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch mowing statistics');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMowers(): Promise<Mower[]> {
|
||||||
|
const response = await fetch(`${API_BASE}/mowers`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch mowers');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async createMower(data: MowerFormData): Promise<Mower> {
|
||||||
|
const response = await fetch(`${API_BASE}/mowers`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to create mower');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
};
|
};
|
@ -2,21 +2,109 @@ export interface Zone {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
imagePath?: string;
|
imagePath?: string;
|
||||||
lastMowedDate: string;
|
lastMowedDate?: string;
|
||||||
intervalDays: number;
|
intervalDays?: number;
|
||||||
|
nextMowDate?: string;
|
||||||
|
scheduleType: 'interval' | 'specific';
|
||||||
area: number;
|
area: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
daysSinceLastMow: number;
|
daysSinceLastMow?: number;
|
||||||
daysUntilNext: number;
|
daysUntilNext?: number;
|
||||||
status: 'ok' | 'due' | 'overdue';
|
status: 'ok' | 'due' | 'overdue' | 'new';
|
||||||
isOverdue: boolean;
|
isOverdue: boolean;
|
||||||
isDueToday: boolean;
|
isDueToday: boolean;
|
||||||
|
isNew: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ZoneFormData {
|
export interface ZoneFormData {
|
||||||
name: string;
|
name: string;
|
||||||
intervalDays: number;
|
intervalDays?: number;
|
||||||
lastMowedDate: string;
|
lastMowedDate?: string;
|
||||||
|
nextMowDate?: string;
|
||||||
|
scheduleType: 'interval' | 'specific';
|
||||||
area: number;
|
area: number;
|
||||||
image?: File;
|
image?: File;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Mower {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
brand?: string;
|
||||||
|
model?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MowerFormData {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
brand?: string;
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MowingHistory {
|
||||||
|
id: number;
|
||||||
|
zoneId: number;
|
||||||
|
zoneName: string;
|
||||||
|
zoneArea: number;
|
||||||
|
mowerId?: number;
|
||||||
|
mowerName?: string;
|
||||||
|
mowerType?: string;
|
||||||
|
mowedDate: string;
|
||||||
|
notes?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
duration?: number; // in minutes
|
||||||
|
weather?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MowingHistoryResponse {
|
||||||
|
data: MowingHistory[];
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPrevPage: boolean;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MowingStats {
|
||||||
|
period: number;
|
||||||
|
totalSessions: number;
|
||||||
|
totalMinutes: number;
|
||||||
|
totalArea: number;
|
||||||
|
mostActiveZone: {
|
||||||
|
name: string;
|
||||||
|
sessions: number;
|
||||||
|
} | null;
|
||||||
|
mostUsedMower: {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
sessions: number;
|
||||||
|
} | null;
|
||||||
|
dailyActivity: {
|
||||||
|
date: string;
|
||||||
|
sessions: number;
|
||||||
|
duration: number;
|
||||||
|
area: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MowingFormData {
|
||||||
|
notes?: string;
|
||||||
|
duration?: number;
|
||||||
|
weather?: string;
|
||||||
|
mowerId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkMowingFormData {
|
||||||
|
selectedZoneIds: number[];
|
||||||
|
notes?: string;
|
||||||
|
totalDuration?: number;
|
||||||
|
weather?: string;
|
||||||
|
mowerId?: number;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user