Версия 0.7.0 Добавлены сезон кошения. При подравнивании срок кошения сдвигается на неделю.

This commit is contained in:
anibilag 2025-08-22 13:13:26 +03:00
parent 0e4e9b364d
commit f27c964ecc
27 changed files with 3855 additions and 354 deletions

View File

@ -1,2 +1,13 @@
VITE_API_URL="http://localhost:3001"
VITE_ENV="development"
NODE_ENV="development"
# Telegram Bot Configuration
# Get your bot token from @BotFather on Telegram
TELEGRAM_BOT_TOKEN="466589147:AAHhNtyR0SN0hI9YUxd-On0TlHDdYbHDxvY"
# Get your chat ID by messaging your bot and visiting:
# https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates
TELEGRAM_CHAT_ID="177464418"

View File

@ -1,2 +1,11 @@
VITE_API_URL=/api
VITE_ENV=docker
# Telegram Bot Configuration
# Get your bot token from @BotFather on Telegram
TELEGRAM_BOT_TOKEN=your_bot_token_here
# Get your chat ID by messaging your bot and visiting:
# https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates
TELEGRAM_CHAT_ID=your_chat_id_here

View File

@ -1,2 +1,10 @@
VITE_API_URL=/api
VITE_ENV=production
# Telegram Bot Configuration
# Get your bot token from @BotFather on Telegram
TELEGRAM_BOT_TOKEN=your_bot_token_here
# Get your chat ID by messaging your bot and visiting:
# https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates
TELEGRAM_CHAT_ID=your_chat_id_here

182
README.md Normal file
View File

@ -0,0 +1,182 @@
# Lawn Mowing Schedule Manager
A comprehensive full-stack application for managing lawn mowing schedules with zone tracking, history logging, and Telegram notifications.
## Features
### 🌱 Zone Management
- Create and manage lawn zones with images
- Set mowing intervals or specific dates
- Track zone areas and status
- Visual site plan with interactive markers
### 📊 Activity Tracking
- Record mowing and trimming sessions
- Bulk operations for multiple zones
- Detailed history with notes and weather
- Time tracking and statistics
### 📱 Telegram Notifications
- Daily reminders for due/overdue zones
- Weekly status reports every Sunday
- Automatic scheduling with cron jobs
- Manual test triggers
### 📈 Analytics
- Zone status dashboard
- Mowing progress tracking
- Weekly and monthly statistics
- Equipment usage tracking
## Setup
### Prerequisites
- Node.js 18+
- npm or yarn
### Installation
1. Clone the repository
```bash
git clone <repository-url>
cd lawn-mowing-scheduler
```
2. Install dependencies
```bash
npm install
```
3. Configure environment variables
```bash
cp .env.example .env
```
Edit `.env` with your settings:
```env
# Telegram Bot Configuration (optional)
TELEGRAM_BOT_TOKEN=your_bot_token_here
TELEGRAM_CHAT_ID=your_chat_id_here
# Database
DATABASE_URL=file:lawn_scheduler.db
# Server
PORT=3001
NODE_ENV=development
```
### Telegram Setup (Optional)
1. Create a bot with @BotFather on Telegram:
- Send `/newbot` to @BotFather
- Follow the instructions to create your bot
- Copy the bot token
2. Get your chat ID:
- Start a chat with your bot
- Send any message
- Visit `https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates`
- Find your chat ID in the response
3. Add the credentials to your `.env` file
4. Test the connection using the Telegram settings in the app
### Development
Start the development server:
```bash
npm run dev
```
This will start:
- Frontend (Vite): http://localhost:5173
- Backend (Express): http://localhost:3001
### Production
Build and start the production server:
```bash
npm run build
npm start
```
## Docker Deployment
### Development
```bash
docker-compose -f docker-compose.dev.yml up --build
```
### Production
```bash
docker-compose up --build
```
## API Endpoints
### Zones
- `GET /api/zones` - Get all zones
- `POST /api/zones` - Create zone
- `PUT /api/zones/:id` - Update zone
- `DELETE /api/zones/:id` - Delete zone
- `POST /api/zones/:id/mow` - Mark zone as mowed
- `POST /api/zones/:id/trim` - Record trimming
- `POST /api/zones/bulk-mow` - Bulk mowing session
### History
- `GET /api/history` - Get mowing history
- `GET /api/history/stats` - Get statistics
### Equipment
- `GET /api/mowers` - Get mowers
- `POST /api/mowers` - Create mower
### Telegram
- `POST /api/telegram/test` - Test connection
- `POST /api/telegram/check-reminders` - Manual reminder
- `POST /api/telegram/weekly-report` - Manual report
## Notification Schedule
- **Daily Reminders**: 8:00 AM - Zones that are due or overdue
- **Weekly Reports**: Sunday 9:00 AM - Complete status summary
## Database Schema
The application uses SQLite with the following main tables:
- `zones` - Lawn zones with scheduling info
- `mowing_history` - Activity log with sessions
- `mowers` - Equipment tracking
## Technologies Used
### Frontend
- React 18 with TypeScript
- Tailwind CSS for styling
- Vite for development and building
### Backend
- Node.js with Express
- SQLite with libsql client
- Multer for file uploads
- node-cron for scheduling
- node-telegram-bot-api for notifications
### Infrastructure
- Docker for containerization
- Nginx for reverse proxy
- File-based SQLite database
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
## License
MIT License - see LICENSE file for details

Binary file not shown.

0
lawn_scheduler.db Normal file
View File

1963
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "lawn-mowing-scheduler",
"private": true,
"version": "0.6.2",
"version": "0.7.0",
"type": "module",
"scripts": {
"dev": "concurrently \"npm run server\" \"npm run client\"",
@ -16,9 +16,13 @@
"dependencies": {
"@libsql/client": "^0.4.0",
"cors": "^2.8.5",
"dotenv": "^17.2.0",
"express": "^4.18.2",
"framer-motion": "^12.23.12",
"lucide-react": "^0.344.0",
"multer": "^2.0.1",
"multer": "2.0.1",
"node-cron": "^4.2.1",
"node-telegram-bot-api": "^0.66.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"

View File

@ -5,6 +5,13 @@ import multer from 'multer';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import cron from 'node-cron';
import { checkMowingReminders, sendWeeklyReport, testTelegramConnection } from './telegram.js';
import dotenv from 'dotenv';
dotenv.config({
path: process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development',
});
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -106,6 +113,31 @@ await db.execute(`
)
`);
// Create season settings table
await db.execute(`
CREATE TABLE IF NOT EXISTS season_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
startMonth INTEGER NOT NULL DEFAULT 5,
startDay INTEGER NOT NULL DEFAULT 1,
endMonth INTEGER NOT NULL DEFAULT 9,
endDay INTEGER NOT NULL DEFAULT 30,
isActive BOOLEAN DEFAULT 1,
createdAt TEXT DEFAULT CURRENT_TIMESTAMP,
updatedAt TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
// Insert default season settings if table is empty
const seasonCountResult = await db.execute('SELECT COUNT(*) as count FROM season_settings');
const seasonCount = seasonCountResult.rows[0].count;
if (seasonCount === 0) {
await db.execute({
sql: 'INSERT INTO season_settings (startMonth, startDay, endMonth, endDay, isActive) VALUES (?, ?, ?, ?, ?)',
args: [5, 1, 9, 30, 1] // May 1 to September 30, active
});
}
// Add new columns to existing zones table if they don't exist
try {
await db.execute(`ALTER TABLE zones ADD COLUMN nextMowDate TEXT`);
@ -218,8 +250,54 @@ if (count === 0) {
}
}
// Helper function to check if we're currently in mowing season
async function isInMowingSeason() {
try {
const result = await db.execute('SELECT * FROM season_settings WHERE id = 1');
if (result.rows.length === 0) return true; // Default to always in season if no settings
const settings = result.rows[0];
if (!settings.isActive) return true; // If season is disabled, always in season
const now = new Date();
const currentMonth = now.getMonth() + 1;
const currentDay = now.getDate();
const startDate = new Date(now.getFullYear(), settings.startMonth - 1, settings.startDay);
const endDate = new Date(now.getFullYear(), settings.endMonth - 1, settings.endDay);
const currentDate = new Date(now.getFullYear(), currentMonth - 1, currentDay);
if (startDate <= endDate) {
// Same year season (e.g., May to September)
return currentDate >= startDate && currentDate <= endDate;
} else {
// Cross-year season (e.g., November to March)
return currentDate >= startDate || currentDate <= endDate;
}
} catch (error) {
console.error('Error checking mowing season:', error);
return true; // Default to in season on error
}
}
// Helper function to calculate zone status
function calculateZoneStatus(zone) {
async function calculateZoneStatus(zone) {
// First check if we're in mowing season
const inSeason = await isInMowingSeason();
if (!inSeason) {
return {
...zone,
daysSinceLastMow: null,
daysUntilNext: null,
status: 'off-season',
isOverdue: false,
isDueToday: false,
isNew: false,
isOffSeason: true
};
}
const today = new Date();
// For new zones that haven't been mowed yet
@ -231,7 +309,8 @@ function calculateZoneStatus(zone) {
status: 'new',
isOverdue: false,
isDueToday: false,
isNew: true
isNew: true,
isOffSeason: false
};
}
@ -240,32 +319,37 @@ function calculateZoneStatus(zone) {
let daysUntilNext;
let status = 'ok';
if (zone.scheduleType === 'specific' && zone.nextMowDate) {
// Always check for nextMowDate first (this could be set by trimming or specific scheduling)
if (zone.nextMowDate) {
// Specific date scheduling
const nextMowDate = new Date(zone.nextMowDate);
daysUntilNext = Math.floor((nextMowDate - today) / (1000 * 60 * 60 * 24));
} else {
} else if (zone.scheduleType === 'interval' && zone.intervalDays) {
// Interval-based scheduling
daysUntilNext = zone.intervalDays - daysSinceLastMow;
} else {
// No valid scheduling information
daysUntilNext = null;
}
if (daysUntilNext < 0) {
if (daysUntilNext !== null && daysUntilNext < 0) {
status = 'overdue';
} else if (daysUntilNext <= 0) {
} else if (daysUntilNext !== null && daysUntilNext <= 0) {
status = 'due';
} else if (daysUntilNext <= 1) {
} else if (daysUntilNext !== null && daysUntilNext <= 1) {
status = 'due';
}
return {
...zone,
daysSinceLastMow,
daysUntilNext,
status,
isOverdue: daysUntilNext < 0,
isDueToday: daysUntilNext <= 0 && daysUntilNext >= -1,
isNew: false
isOverdue: daysUntilNext !== null && daysUntilNext < 0,
isDueToday: daysUntilNext !== null && daysUntilNext <= 0 && daysUntilNext >= -1,
isNew: false,
isOffSeason: false
};
}
@ -346,7 +430,7 @@ app.get('/api/zones', async (req, res) => {
area: row.area || 0,
createdAt: row.createdAt
}));
const zonesWithStatus = zones.map(calculateZoneStatus);
const zonesWithStatus = await Promise.all(zones.map(calculateZoneStatus));
res.json(zonesWithStatus);
} catch (error) {
res.status(500).json({ error: error.message });
@ -672,8 +756,8 @@ app.put('/api/zones/:id', upload.single('image'), async (req, res) => {
area: updatedResult.rows[0].area || 0,
createdAt: updatedResult.rows[0].createdAt
};
const zoneWithStatus = calculateZoneStatus(updatedZone);
const zoneWithStatus = await calculateZoneStatus(zone);
res.json(zoneWithStatus);
} catch (error) {
res.status(500).json({ error: error.message });
@ -766,15 +850,15 @@ app.post('/api/zones/:id/mow', async (req, res) => {
area: updatedResult.rows[0].area || 0,
createdAt: updatedResult.rows[0].createdAt
};
const zoneWithStatus = calculateZoneStatus(updatedZone);
const zoneWithStatus = await calculateZoneStatus(zone);
res.json(zoneWithStatus);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Trimming endpoint (doesn't update zone schedule)
// Trimming endpoint (update zone schedule by the week)
app.post('/api/zones/:id/trim', async (req, res) => {
try {
const { notes, duration, weather, mowerId } = req.body;
@ -789,15 +873,48 @@ app.post('/api/zones/:id/trim', async (req, res) => {
if (zoneResult.rows.length === 0) {
return res.status(404).json({ error: 'Zone not found' });
}
const zone = zoneResult.rows[0];
// Update the zone's next mowing date by adding one week
// This applies the rule that trimming delays mowing by a week
let updatedNextMowDate = null;
if (zone.scheduleType === 'interval' && zone.intervalDays) {
// For interval scheduling, calculate new next mow date
const baseDate = zone.lastMowedDate ? new Date(zone.lastMowedDate) : new Date();
const originalNextMow = new Date(baseDate.getTime() + (zone.intervalDays * 24 * 60 * 60 * 1000));
updatedNextMowDate = new Date(originalNextMow.getTime() + (7 * 24 * 60 * 60 * 1000)).toISOString();
// Update the zone's nextMowDate
await db.execute({
sql: 'UPDATE zones SET nextMowDate = ? WHERE id = ?',
args: [updatedNextMowDate, req.params.id]
});
} else if (zone.scheduleType === 'specific' && zone.nextMowDate) {
// For specific date scheduling, push the date forward by one week
const currentNextMow = new Date(zone.nextMowDate);
updatedNextMowDate = new Date(currentNextMow.getTime() + (7 * 24 * 60 * 60 * 1000)).toISOString();
// Update the zone's nextMowDate
await db.execute({
sql: 'UPDATE zones SET nextMowDate = ? WHERE id = ?',
args: [updatedNextMowDate, req.params.id]
});
}
// Add to mowing history with trimming activity type
// Note: We don't update the zone's lastMowedDate or nextMowDate for trimming
await db.execute({
sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather, activityType) VALUES (?, ?, ?, ?, ?, ?, ?)',
args: [req.params.id, mowerId || null, today, notes || null, duration || null, weather || null, 'trimming']
});
res.json({ success: true, message: 'Trimming recorded successfully' });
res.json({
success: true,
message: 'Trimming recorded successfully. Next mowing date has been delayed by one week.',
updatedNextMowDate
});
} catch (error) {
res.status(500).json({ error: error.message });
}
@ -859,6 +976,32 @@ app.post('/api/zones/bulk-mow', async (req, res) => {
}
await db.execute({ sql, args });
} else if (activityType === 'trimming') {
// For trimming, delay the next mowing date by one week
let updatedNextMowDate = null;
if (zone.scheduleType === 'interval' && zone.intervalDays) {
// For interval scheduling, calculate new next mow date
const baseDate = zone.lastMowedDate ? new Date(zone.lastMowedDate) : new Date();
const originalNextMow = new Date(baseDate.getTime() + (zone.intervalDays * 24 * 60 * 60 * 1000));
updatedNextMowDate = new Date(originalNextMow.getTime() + (7 * 24 * 60 * 60 * 1000)).toISOString();
// Update the zone's nextMowDate
await db.execute({
sql: 'UPDATE zones SET nextMowDate = ? WHERE id = ?',
args: [updatedNextMowDate, zone.id]
});
} else if (zone.scheduleType === 'specific' && zone.nextMowDate) {
// For specific date scheduling, push the date forward by one week
const currentNextMow = new Date(zone.nextMowDate);
updatedNextMowDate = new Date(currentNextMow.getTime() + (7 * 24 * 60 * 60 * 1000)).toISOString();
// Update the zone's nextMowDate
await db.execute({
sql: 'UPDATE zones SET nextMowDate = ? WHERE id = ?',
args: [updatedNextMowDate, zone.id]
});
}
}
// Add to mowing history with session ID
@ -869,8 +1012,8 @@ app.post('/api/zones/bulk-mow', async (req, res) => {
}
res.json({
success: true,
message: `Successfully recorded ${activityType} session for ${zones.length} zones`,
success: true,
message: `Successfully recorded ${activityType} session for ${zones.length} zones${activityType === 'trimming' ? '. Next mowing dates have been delayed by one week.' : ''}`,
sessionId,
zonesUpdated: zones.length
});
@ -891,7 +1034,147 @@ app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString() });
});
// Telegram test endpoint
app.post('/api/telegram/test', async (req, res) => {
try {
const result = await testTelegramConnection();
res.json(result);
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});
// Manual trigger for mowing reminders
app.post('/api/telegram/check-reminders', async (req, res) => {
try {
await checkMowingReminders();
res.json({ success: true, message: 'Mowing reminders checked and sent if needed' });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});
// Manual trigger for weekly report
app.post('/api/telegram/weekly-report', async (req, res) => {
try {
await sendWeeklyReport();
res.json({ success: true, message: 'Weekly report sent successfully' });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});
// Season settings routes
app.get('/api/season', async (req, res) => {
try {
const result = await db.execute('SELECT * FROM season_settings WHERE id = 1');
if (result.rows.length === 0) {
// Return default settings if none exist
res.json({
startMonth: 5,
startDay: 1,
endMonth: 9,
endDay: 30,
isActive: true
});
} else {
const settings = result.rows[0];
res.json({
id: settings.id,
startMonth: settings.startMonth,
startDay: settings.startDay,
endMonth: settings.endMonth,
endDay: settings.endDay,
isActive: settings.isActive,
createdAt: settings.createdAt,
updatedAt: settings.updatedAt
});
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.put('/api/season', async (req, res) => {
try {
const { startMonth, startDay, endMonth, endDay, isActive } = req.body;
// Check if settings exist
const existingResult = await db.execute('SELECT * FROM season_settings WHERE id = 1');
if (existingResult.rows.length === 0) {
// Create new settings
const result = await db.execute({
sql: 'INSERT INTO season_settings (startMonth, startDay, endMonth, endDay, isActive) VALUES (?, ?, ?, ?, ?)',
args: [startMonth, startDay, endMonth, endDay, isActive]
});
const newSettings = await db.execute({
sql: 'SELECT * FROM season_settings WHERE id = ?',
args: [result.lastInsertRowid]
});
res.json({
id: newSettings.rows[0].id,
startMonth: newSettings.rows[0].startMonth,
startDay: newSettings.rows[0].startDay,
endMonth: newSettings.rows[0].endMonth,
endDay: newSettings.rows[0].endDay,
isActive: newSettings.rows[0].isActive,
createdAt: newSettings.rows[0].createdAt,
updatedAt: newSettings.rows[0].updatedAt
});
} else {
// Update existing settings
await db.execute({
sql: 'UPDATE season_settings SET startMonth = ?, startDay = ?, endMonth = ?, endDay = ?, isActive = ?, updatedAt = CURRENT_TIMESTAMP WHERE id = 1',
args: [startMonth, startDay, endMonth, endDay, isActive]
});
const updatedSettings = await db.execute('SELECT * FROM season_settings WHERE id = 1');
res.json({
id: updatedSettings.rows[0].id,
startMonth: updatedSettings.rows[0].startMonth,
startDay: updatedSettings.rows[0].startDay,
endMonth: updatedSettings.rows[0].endMonth,
endDay: updatedSettings.rows[0].endDay,
isActive: updatedSettings.rows[0].isActive,
createdAt: updatedSettings.rows[0].createdAt,
updatedAt: updatedSettings.rows[0].updatedAt
});
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on http://0.0.0.0:${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
// Setup cron jobs for Telegram notifications
if (process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID) {
console.log('Setting up Telegram notifications...');
// Daily mowing reminders at 8:00 AM
cron.schedule('0 8 * * *', () => {
console.log('Running daily mowing reminder check...');
checkMowingReminders();
}, {
timezone: 'Europe/Moscow' // Adjust timezone as needed
});
// Weekly report every Sunday at 9:00 AM
cron.schedule('0 9 * * 0', () => {
console.log('Sending weekly lawn care report...');
sendWeeklyReport();
}, {
timezone: 'Europe/Moscow' // Adjust timezone as needed
});
console.log('Telegram notifications scheduled successfully');
} else {
console.log('Telegram notifications not configured (missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID)');
}
});

272
server/telegram.js Normal file
View File

@ -0,0 +1,272 @@
import TelegramBot from 'node-telegram-bot-api';
import { createClient } from '@libsql/client';
// Initialize Telegram bot
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID;
let bot = null;
if (TELEGRAM_BOT_TOKEN) {
bot = new TelegramBot(TELEGRAM_BOT_TOKEN, { polling: false });
}
// Initialize database connection
const db = createClient({
url: 'file:lawn_scheduler.db'
});
// Helper function to calculate zone status
function calculateZoneStatus(zone) {
const today = new Date();
if (!zone.lastMowedDate) {
return {
...zone,
daysSinceLastMow: null,
daysUntilNext: null,
status: 'new',
isOverdue: false,
isDueToday: false,
isNew: true
};
}
const lastMowed = new Date(zone.lastMowedDate);
const daysSinceLastMow = Math.floor((today - lastMowed) / (1000 * 60 * 60 * 24));
let daysUntilNext;
let status = 'ok';
if (zone.scheduleType === 'specific' && zone.nextMowDate) {
const nextMowDate = new Date(zone.nextMowDate);
daysUntilNext = Math.floor((nextMowDate - today) / (1000 * 60 * 60 * 24));
} else {
daysUntilNext = zone.intervalDays - daysSinceLastMow;
}
if (daysUntilNext < 0) {
status = 'overdue';
} else if (daysUntilNext <= 0) {
status = 'due';
} else if (daysUntilNext <= 1) {
status = 'due';
}
return {
...zone,
daysSinceLastMow,
daysUntilNext,
status,
isOverdue: daysUntilNext < 0,
isDueToday: daysUntilNext <= 0 && daysUntilNext >= -1,
isNew: false
};
}
// Send Telegram message
export async function sendTelegramMessage(message) {
if (!bot || !TELEGRAM_CHAT_ID) {
console.log('Telegram not configured. Message would be:', message);
return false;
}
try {
await bot.sendMessage(TELEGRAM_CHAT_ID, message, { parse_mode: 'Markdown' });
console.log('Telegram message sent successfully');
return true;
} catch (error) {
console.error('Failed to send Telegram message:', error);
return false;
}
}
// Check for zones that need mowing and send notifications
export async function checkMowingReminders() {
try {
// First check if we're in mowing season
const seasonResult = await db.execute('SELECT * FROM season_settings WHERE id = 1');
if (seasonResult.rows.length > 0) {
const settings = seasonResult.rows[0];
if (settings.isActive) {
const now = new Date();
const currentMonth = now.getMonth() + 1;
const currentDay = now.getDate();
const startDate = new Date(now.getFullYear(), settings.startMonth - 1, settings.startDay);
const endDate = new Date(now.getFullYear(), settings.endMonth - 1, settings.endDay);
const currentDate = new Date(now.getFullYear(), currentMonth - 1, currentDay);
let inSeason;
if (startDate <= endDate) {
inSeason = currentDate >= startDate && currentDate <= endDate;
} else {
inSeason = currentDate >= startDate || currentDate <= endDate;
}
if (!inSeason) {
console.log('Not in mowing season, skipping reminders');
return;
}
}
}
const result = await db.execute('SELECT * FROM zones ORDER BY name');
const zones = result.rows.map(row => ({
id: row.id,
name: row.name,
lastMowedDate: row.lastMowedDate,
intervalDays: row.intervalDays,
nextMowDate: row.nextMowDate,
scheduleType: row.scheduleType || 'interval',
area: row.area || 0,
}));
const zonesWithStatus = zones.map(calculateZoneStatus);
// Find zones that are due or overdue
const dueZones = zonesWithStatus.filter(zone => zone.isDueToday);
const overdueZones = zonesWithStatus.filter(zone => zone.isOverdue);
const newZones = zonesWithStatus.filter(zone => zone.isNew);
if (dueZones.length > 0 || overdueZones.length > 0 || newZones.length > 0) {
let message = '🌱 *Lawn Mowing Reminder*\n\n';
if (overdueZones.length > 0) {
message += '🚨 *OVERDUE ZONES:*\n';
overdueZones.forEach(zone => {
const daysOverdue = Math.abs(zone.daysUntilNext);
message += `${zone.name} - ${daysOverdue} day${daysOverdue > 1 ? 's' : ''} overdue\n`;
});
message += '\n';
}
if (dueZones.length > 0) {
message += '⏰ *DUE TODAY:*\n';
dueZones.forEach(zone => {
message += `${zone.name}\n`;
});
message += '\n';
}
if (newZones.length > 0) {
message += '🆕 *NEW ZONES (Not yet mowed):*\n';
newZones.forEach(zone => {
message += `${zone.name}\n`;
});
message += '\n';
}
message += `Total zones needing attention: ${overdueZones.length + dueZones.length + newZones.length}`;
await sendTelegramMessage(message);
}
} catch (error) {
console.error('Error checking mowing reminders:', error);
}
}
// Generate and send weekly report
export async function sendWeeklyReport() {
try {
const result = await db.execute('SELECT * FROM zones ORDER BY name');
const zones = result.rows.map(row => ({
id: row.id,
name: row.name,
lastMowedDate: row.lastMowedDate,
intervalDays: row.intervalDays,
nextMowDate: row.nextMowDate,
scheduleType: row.scheduleType || 'interval',
area: row.area || 0,
}));
const zonesWithStatus = zones.map(calculateZoneStatus);
// Get weekly statistics
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
const weeklyHistoryResult = await db.execute({
sql: `
SELECT
COUNT(*) as sessions,
SUM(duration) as totalMinutes,
SUM(z.area) as totalArea,
COUNT(DISTINCT mh.zoneId) as uniqueZones
FROM mowing_history mh
JOIN zones z ON mh.zoneId = z.id
WHERE mh.mowedDate >= ?
`,
args: [weekAgo.toISOString()]
});
const weeklyStats = weeklyHistoryResult.rows[0];
// Count zones by status
const overdueCount = zonesWithStatus.filter(zone => zone.isOverdue).length;
const dueCount = zonesWithStatus.filter(zone => zone.isDueToday).length;
const newCount = zonesWithStatus.filter(zone => zone.isNew).length;
const okCount = zonesWithStatus.filter(zone => zone.status === 'ok').length;
const totalArea = zones.reduce((sum, zone) => sum + zone.area, 0);
const mowedArea = zonesWithStatus
.filter(zone => zone.status === 'ok')
.reduce((sum, zone) => sum + zone.area, 0);
const mowedPercentage = totalArea > 0 ? (mowedArea / totalArea) * 100 : 0;
let message = '📊 *Weekly Lawn Care Report*\n';
message += `📅 ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}\n\n`;
// Overall status
message += '🏡 *ZONE STATUS:*\n';
message += `✅ Up to date: ${okCount} zones\n`;
message += `🆕 New zones: ${newCount} zones\n`;
message += `⏰ Due today: ${dueCount} zones\n`;
message += `🚨 Overdue: ${overdueCount} zones\n`;
message += `📐 Total area: ${totalArea.toLocaleString()} sq ft\n`;
message += `🌱 Mowed area: ${mowedPercentage.toFixed(1)}%\n\n`;
// Weekly activity
message += '📈 *THIS WEEK\'S ACTIVITY:*\n';
message += `🔄 Mowing sessions: ${weeklyStats.sessions || 0}\n`;
message += `⏱️ Total time: ${weeklyStats.totalMinutes ? Math.floor(weeklyStats.totalMinutes / 60) : 0}h ${weeklyStats.totalMinutes ? weeklyStats.totalMinutes % 60 : 0}m\n`;
message += `📐 Area mowed: ${(weeklyStats.totalArea || 0).toLocaleString()} sq ft\n`;
message += `🎯 Zones maintained: ${weeklyStats.uniqueZones || 0}\n\n`;
// Zones needing attention
const needsAttention = [...zonesWithStatus.filter(zone => zone.isOverdue || zone.isDueToday || zone.isNew)];
if (needsAttention.length > 0) {
message += '⚠️ *NEEDS ATTENTION:*\n';
needsAttention.forEach(zone => {
let status = '';
if (zone.isOverdue) {
status = `🚨 ${Math.abs(zone.daysUntilNext)} days overdue`;
} else if (zone.isDueToday) {
status = '⏰ Due today';
} else if (zone.isNew) {
status = '🆕 Not yet mowed';
}
message += `${zone.name} - ${status}\n`;
});
} else {
message += '🎉 *All zones are up to date!*\n';
}
await sendTelegramMessage(message);
} catch (error) {
console.error('Error generating weekly report:', error);
}
}
// Test Telegram connection
export async function testTelegramConnection() {
if (!bot || !TELEGRAM_CHAT_ID) {
return { success: false, message: 'Telegram not configured' };
}
try {
await sendTelegramMessage('🤖 *Lawn Care Manager*\n\nTelegram notifications are working correctly!');
return { success: true, message: 'Test message sent successfully' };
} catch (error) {
return { success: false, message: error.message };
}
}

View File

@ -152,7 +152,7 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
</h2>
<p className="text-sm text-gray-600 mt-1">
{activityType === 'trimming'
? 'Выберите несколько зон, обрезанных за один сеанс (не сбрасывает расписание скашивания).'
? 'Выберите несколько зон, обрезанных за один сеанс (сдвиг скашивания на одну неделю в каждой зоне).'
: 'Выберите несколько зон, скошенных за один сеанс'
}
</p>
@ -203,7 +203,7 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
{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(', ')}
<strong>Выбрано:</strong> {selectedZones.map(z => z.name).join(', ')}
<span className="ml-2">
(Общая площадь: {totalSelectedArea.toLocaleString()} м2)
</span>

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Plus, Filter, Calendar, Scissors, AlertTriangle, Square, CheckCircle, Clock, Map, History } from './Icons';
import { Plus, Filter, Calendar, Scissors, AlertTriangle, Square, CheckCircle, Clock, Map, History, Zap, Send, Settings, X } from './Icons';
import { Zone, MowingFormData, BulkMowingFormData } from '../types/zone';
import { api } from '../services/api';
import ZoneCard from './ZoneCard';
@ -8,6 +8,8 @@ import SitePlan from './SitePlan';
import HistoryView from './HistoryView';
import MowingModal from './MowingModal';
import BulkMowingModal from './BulkMowingModal';
import TelegramSettings from './TelegramSettings';
import SeasonSettingsComponent from './SeasonSettings';
type FilterType = 'all' | 'due' | 'overdue' | 'new';
type ViewType = 'dashboard' | 'sitePlan' | 'history';
@ -26,9 +28,12 @@ const Dashboard: React.FC = () => {
const [showBulkTrimmingModal, setShowBulkTrimmingModal] = useState(false);
const [mowingZone, setMowingZone] = useState<Zone | null>(null);
const [trimmingZone, setTrimmingZone] = useState<Zone | null>(null);
const [showTelegramSettings, setShowTelegramSettings] = useState(false);
const [showSeasonSettings, setShowSeasonSettings] = useState(false);
const [loading, setLoading] = useState(true);
const [mowingLoading, setMowingLoading] = useState(false);
const [trimmingLoading, setTrimmingLoading] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
loadZones();
@ -94,13 +99,14 @@ const Dashboard: React.FC = () => {
const handleTrimmingSubmit = async (data: MowingFormData) => {
if (!trimmingZone) return;
setTrimmingLoading(true);
try {
await api.markAsTrimmed(trimmingZone.id, data);
setShowTrimmingModal(false);
setTrimmingZone(null);
// Note: Don't reload zones since trimming doesn't affect mowing schedule
// Перезагрузка зон, поскольку подравнивание теперь влияет на график скашивания (задержки на 1 неделю)
loadZones();
} catch (error) {
console.error('Failed to mark as trimmed:', error);
} finally {
@ -125,7 +131,8 @@ const Dashboard: React.FC = () => {
try {
await api.bulkMarkAsMowed(data);
setShowBulkTrimmingModal(false);
// Note: Don't reload zones since trimming doesn't affect mowing schedule
// Перезагрузка зон, поскольку подравнивание теперь влияет на график скашивания (задержки на 1 неделю)
loadZones();
} catch (error) {
console.error('Failed to record bulk trimming session:', error);
} finally {
@ -170,12 +177,12 @@ const Dashboard: React.FC = () => {
const newCount = zones.filter(zone => zone.isNew).length;
const okCount = zones.filter(zone => zone.status === 'ok').length;
const totalArea = zones.reduce((sum, zone) => sum + zone.area, 0);
// Calculate mowed vs remaining area based on status
const mowedArea = zones
.filter(zone => zone.status === 'ok')
.reduce((sum, zone) => sum + zone.area, 0);
.filter(zone => zone.status === 'ok')
.reduce((sum, zone) => sum + zone.area, 0);
const mowedPercentage = totalArea > 0 ? (mowedArea / totalArea) * 100 : 0;
// Check if there are zones that need mowing for bulk action
@ -185,299 +192,426 @@ const Dashboard: React.FC = () => {
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600"></div>
</div>
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
<div className="min-h-screen bg-gray-50">
<div className="flex h-screen">
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<div className={`fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0 ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}>
<div className="flex items-center justify-between h-16 px-6 border-b">
<div className="flex items-center gap-3">
<Scissors className="h-8 w-8 text-green-600" />
Менеджер по уходу за газоном
</h1>
<p className="mt-2 text-gray-600">Следите за своим графиком стрижки газона</p>
<h1 className="text-xl font-bold text-gray-900">Уход за газоном</h1>
</div>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden text-gray-400 hover:text-gray-600"
>
<X className="h-6 w-6" />
</button>
</div>
<div className="flex items-center gap-3">
{/* View Toggle */}
<div className="flex bg-gray-100 rounded-lg p-1">
<nav className="mt-6 px-3">
<div className="space-y-1">
{/* Navigation Items */}
<button
onClick={() => setView('dashboard')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
view === 'dashboard'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Calendar className="h-4 w-4 inline mr-2" />
Панель
</button>
<button
onClick={() => setView('sitePlan')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
view === 'sitePlan'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Map className="h-4 w-4 inline mr-2" />
План участка
</button>
<button
onClick={() => setView('history')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
view === 'history'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
onClick={() => {
setView('dashboard');
setSidebarOpen(false);
}}
className={`w-full flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-200 ${
view === 'dashboard'
? 'bg-green-100 text-green-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<History className="h-4 w-4 inline mr-2" />
<Calendar className="h-5 w-5 mr-3" />
Сводка
</button>
<button
onClick={() => {
setView('sitePlan');
setSidebarOpen(false);
}}
className={`w-full flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-200 ${
view === 'sitePlan'
? 'bg-green-100 text-green-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<Map className="h-5 w-5 mr-3" />
План участка
</button>
<button
onClick={() => {
setView('history');
setSidebarOpen(false);
}}
className={`w-full flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-200 ${
view === 'history'
? 'bg-green-100 text-green-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<History className="h-5 w-5 mr-3" />
История
</button>
</div>
<button
onClick={() => setShowForm(true)}
className="bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 transition-colors duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="h-5 w-5" />
Новая зона
</button>
<div className="mt-8">
<h3 className="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Действия
</h3>
<div className="mt-2 space-y-1">
<button
onClick={() => {
setShowForm(true);
setSidebarOpen(false);
}}
className="w-full flex items-center px-3 py-2 text-sm font-medium text-gray-600 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors duration-200"
>
<Plus className="h-5 w-5 mr-3" />
Добавить зону
</button>
{/* Bulk Actions - only show on dashboard with zones needing mowing */}
{view === 'dashboard' && zonesNeedingMowing.length > 0 && (
<>
<button
onClick={() => {
setShowBulkMowingModal(true);
setSidebarOpen(false);
}}
className="w-full flex items-center px-3 py-2 text-sm font-medium text-orange-600 rounded-md hover:bg-orange-50 hover:text-orange-700 transition-colors duration-200"
>
<Scissors className="h-5 w-5 mr-3" />
Bulk Mow ({zonesNeedingMowing.length})
</button>
<button
onClick={() => {
setShowBulkTrimmingModal(true);
setSidebarOpen(false);
}}
className="w-full flex items-center px-3 py-2 text-sm font-medium text-purple-600 rounded-md hover:bg-purple-50 hover:text-purple-700 transition-colors duration-200"
>
<Zap className="h-5 w-5 mr-3" />
Bulk Trim
</button>
</>
)}
</div>
</div>
<div className="mt-8">
<h3 className="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Настройки
</h3>
<div className="mt-2 space-y-1">
<button
onClick={() => {
setShowTelegramSettings(true);
setSidebarOpen(false);
}}
className="w-full flex items-center px-3 py-2 text-sm font-medium text-gray-600 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors duration-200"
>
<Send className="h-5 w-5 mr-3" />
Телеграм
</button>
<button
onClick={() => {
setShowSeasonSettings(true);
setSidebarOpen(false);
}}
className="w-full flex items-center px-3 py-2 text-sm font-medium text-gray-600 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors duration-200"
>
<Settings className="h-5 w-5 mr-3" />
Сезон
</button>
</div>
</div>
</nav>
</div>
{/* Main content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Top bar */}
<div className="bg-white shadow-sm border-b px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={() => setSidebarOpen(true)}
className="lg:hidden text-gray-500 hover:text-gray-700"
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div>
<h2 className="text-2xl font-bold text-gray-900">
{view === 'dashboard' && 'Dashboard'}
{view === 'sitePlan' && 'Site Plan'}
{view === 'history' && 'History'}
</h2>
<p className="text-gray-600">
{view === 'dashboard' && 'Keep track of your lawn mowing schedule'}
{view === 'sitePlan' && 'Visualize your property zones'}
{view === 'history' && 'Track your lawn care activities'}
</p>
</div>
</div>
</div>
</div>
{/* Main content area */}
<div className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto px-6 py-8">
{view === 'history' ? (
<HistoryView zones={zones} />
) : view === 'sitePlan' ? (
<SitePlan
zones={zones}
onZoneSelect={handleZoneSelect}
selectedZoneId={selectedZoneId}
/>
) : (
<>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-6 mb-8">
<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">{zones.length}</p>
</div>
<Calendar 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">{totalArea.toLocaleString()}</p>
<p className="text-xs text-gray-500">sq ft</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-emerald-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Актуально</p>
<p className="text-2xl font-bold text-gray-900">{okCount}</p>
<p className="text-xs text-gray-500">zones maintained</p>
</div>
<CheckCircle className="h-8 w-8 text-emerald-500" />
</div>
</div>
{/*
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-blue-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">New Zones</p>
<p className="text-2xl font-bold text-gray-900">{newCount}</p>
<p className="text-xs text-gray-500">not yet mowed</p>
</div>
<Calendar className="h-8 w-8 text-blue-500" />
</div>
</div>
*/}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-amber-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Внимание!</p>
<p className="text-2xl font-bold text-gray-900">{dueCount + overdueCount + newCount}</p>
<p className="text-xs text-gray-500">zones to mow</p>
</div>
<Clock className="h-8 w-8 text-amber-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-2xl font-bold text-gray-900">{dueCount}</p>
</div>
<Scissors className="h-8 w-8 text-orange-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-red-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">{overdueCount}</p>
</div>
<AlertTriangle className="h-8 w-8 text-red-500" />
</div>
</div>
</div>
{/* Progress Bar */}
{totalArea > 0 && (
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Ход скашивания</h3>
<span className="text-sm text-gray-600">
{mowedArea.toLocaleString()} / {totalArea.toLocaleString()} м2
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-4 mb-2">
<div
className="bg-gradient-to-r from-green-500 to-emerald-600 h-4 rounded-full transition-all duration-500 ease-out"
style={{ width: `${mowedPercentage}%` }}
></div>
</div>
<div className="flex justify-between text-sm text-gray-600">
<span className="flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-emerald-500" />
{mowedPercentage.toFixed(1)}% Завершено ({okCount} зон)
</span>
<span className="flex items-center gap-1">
<Clock className="h-4 w-4 text-amber-500" />
{(100 - mowedPercentage).toFixed(1)}% Осталось ({dueCount + overdueCount + newCount} зон)
</span>
</div>
</div>
)}
{/* Bulk Mowing Notice */}
{zonesNeedingMowing.length > 1 && (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Scissors className="h-5 w-5 text-orange-600" />
<div>
<h4 className="text-sm font-medium text-orange-800">
Необходимо скосить или подравнять несколько зон
</h4>
<p className="text-sm text-orange-700">
Используйте массовые операции для нескольких зон, за один сеанс, с пропорциональным распределением времени
</p>
</div>
</div>
<div className="flex gap-2">
<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>
<button
onClick={() => setShowBulkTrimmingModal(true)}
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
>
Подравнование
</button>
</div>
</div>
</div>
)}
{/* Filter Buttons */}
<div className="mb-6">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-gray-500" />
<span className="text-sm font-medium text-gray-700">Filter:</span>
<div className="flex gap-2">
{[
{ key: 'all' as FilterType, label: 'All Zones', count: zones.length },
{ key: 'new' as FilterType, label: 'New Zones', count: newCount },
{ key: 'due' as FilterType, label: 'Due Today', count: dueCount },
{ key: 'overdue' as FilterType, label: 'Overdue', count: overdueCount },
].map(({ key, label, count }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors duration-200 ${
filter === key
? 'bg-green-600 text-white shadow-md'
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
}`}
>
{label} ({count})
</button>
))}
</div>
</div>
</div>
{/* Zones Grid */}
{filteredZones.length === 0 ? (
<div className="text-center py-12">
<Scissors className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No zones found</h3>
<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.`}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredZones.map(zone => (
<div
key={zone.id}
id={`zone-card-${zone.id}`}
className={`transition-all duration-300 ${
selectedZoneId === zone.id
? 'ring-4 ring-blue-300 ring-opacity-50 scale-105'
: ''
}`}
>
<ZoneCard
zone={zone}
onMarkAsMowed={handleMarkAsMowed}
onMarkAsTrimmed={handleMarkAsTrimmed}
onEdit={(zone) => {
setEditingZone(zone);
setShowForm(true);
}}
onDelete={handleDeleteZone}
/>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
{view === 'history' ? (
<HistoryView zones={zones} />
) : view === 'sitePlan' ? (
<SitePlan
zones={zones}
onZoneSelect={handleZoneSelect}
selectedZoneId={selectedZoneId}
/>
) : (
<>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-6 mb-8">
<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">{zones.length}</p>
</div>
<Calendar 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">{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-emerald-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Актуально</p>
<p className="text-2xl font-bold text-gray-900">{okCount}</p>
<p className="text-xs text-gray-500">зон обслужено</p>
</div>
<CheckCircle className="h-8 w-8 text-emerald-500" />
</div>
</div>
{/*
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-blue-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Новые зоны</p>
<p className="text-2xl font-bold text-gray-900">{newCount}</p>
<p className="text-xs text-gray-500">еще не косились</p>
</div>
<Calendar className="h-8 w-8 text-blue-500" />
</div>
</div>
*/}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-amber-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Внимание!</p>
<p className="text-2xl font-bold text-gray-900">{dueCount + overdueCount + newCount}</p>
<p className="text-xs text-gray-500">зон для покоса</p>
</div>
<Clock className="h-8 w-8 text-amber-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-2xl font-bold text-gray-900">{dueCount}</p>
</div>
<Scissors className="h-8 w-8 text-orange-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-red-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">{overdueCount}</p>
</div>
<AlertTriangle className="h-8 w-8 text-red-500" />
</div>
</div>
</div>
{/* Progress Bar */}
{totalArea > 0 && (
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Ход скашивания</h3>
<span className="text-sm text-gray-600">
{mowedArea.toLocaleString()} / {totalArea.toLocaleString()} м2
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-4 mb-2">
<div
className="bg-gradient-to-r from-green-500 to-emerald-600 h-4 rounded-full transition-all duration-500 ease-out"
style={{ width: `${mowedPercentage}%` }}
></div>
</div>
<div className="flex justify-between text-sm text-gray-600">
<span className="flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-emerald-500" />
{mowedPercentage.toFixed(1)}% Завершено ({okCount} зон)
</span>
<span className="flex items-center gap-1">
<Clock className="h-4 w-4 text-amber-500" />
{(100 - mowedPercentage).toFixed(1)}% Осталось ({dueCount + overdueCount} зон)
</span>
</div>
</div>
)}
{/* Bulk Mowing Notice */}
{zonesNeedingMowing.length > 1 && (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Scissors className="h-5 w-5 text-orange-600" />
<div>
<h4 className="text-sm font-medium text-orange-800">
Необходимо скосить или подравнять несколько зон
</h4>
<p className="text-sm text-orange-700">
Используйте массовые операции для нескольких зон, за один сеанс, с пропорциональным распределением времени
</p>
</div>
</div>
<div className="flex gap-2">
<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>
<button
onClick={() => setShowBulkTrimmingModal(true)}
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
>
Подравнование
</button>
</div>
</div>
</div>
)}
{/* Filter Buttons */}
<div className="mb-6">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-gray-500" />
<span className="text-sm font-medium text-gray-700">Фильтр:</span>
<div className="flex gap-2">
{[
{ key: 'all' as FilterType, label: 'Все зоны', count: zones.length },
{ key: 'new' as FilterType, label: 'Новые зоны', count: newCount },
{ key: 'due' as FilterType, label: 'Срок - сегодня', count: dueCount },
{ key: 'overdue' as FilterType, label: 'Срок прошел', count: overdueCount },
].map(({ key, label, count }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors duration-200 ${
filter === key
? 'bg-green-600 text-white shadow-md'
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
}`}
>
{label} ({count})
</button>
))}
</div>
</div>
</div>
{/* Zones Grid */}
{filteredZones.length === 0 ? (
<div className="text-center py-12">
<Scissors className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">Не найдено ни одной зоны</h3>
<p className="mt-1 text-sm text-gray-500">
{filter === 'all' ? 'Get started by adding your first lawn zone.' : `Нет подходящих зон для "${filter}" фильтра.`}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredZones.map(zone => (
<div
key={zone.id}
id={`zone-card-${zone.id}`}
className={`transition-all duration-300 ${
selectedZoneId === zone.id
? 'ring-4 ring-blue-300 ring-opacity-50 scale-105'
: ''
}`}
>
<ZoneCard
zone={zone}
onMarkAsMowed={handleMarkAsMowed}
onMarkAsTrimmed={handleMarkAsTrimmed}
onEdit={(zone) => {
setEditingZone(zone);
setShowForm(true);
}}
onDelete={handleDeleteZone}
/>
</div>
))}
</div>
)}
</>
)}
{/* Zone Form Modal */}
{showForm && (
<ZoneForm
zone={editingZone}
onSubmit={handleFormSubmit}
onCancel={() => {
setShowForm(false);
setEditingZone(null);
}}
/>
<ZoneForm
zone={editingZone}
onSubmit={handleFormSubmit}
onCancel={() => {
setShowForm(false);
setEditingZone(null);
}}
/>
)}
{/* Single Zone Mowing Modal */}
@ -496,16 +630,16 @@ const Dashboard: React.FC = () => {
{/* Single Zone Trimming Modal */}
{showTrimmingModal && trimmingZone && (
<MowingModal
zoneName={trimmingZone.name}
activityType="trimming"
onSubmit={handleTrimmingSubmit}
onCancel={() => {
setShowTrimmingModal(false);
setTrimmingZone(null);
}}
loading={trimmingLoading}
/>
<MowingModal
zoneName={trimmingZone.name}
activityType="trimming"
onSubmit={handleTrimmingSubmit}
onCancel={() => {
setShowTrimmingModal(false);
setTrimmingZone(null);
}}
loading={trimmingLoading}
/>
)}
{/* Bulk Mowing Modal */}
{showBulkMowingModal && (
@ -520,16 +654,29 @@ const Dashboard: React.FC = () => {
{/* Bulk Trimming Modal */}
{showBulkTrimmingModal && (
<BulkMowingModal
zones={zones}
activityType="trimming"
onSubmit={handleBulkTrimmingSubmit}
onCancel={() => setShowBulkTrimmingModal(false)}
loading={trimmingLoading}
/>
<BulkMowingModal
zones={zones}
activityType="trimming"
onSubmit={handleBulkTrimmingSubmit}
onCancel={() => setShowBulkTrimmingModal(false)}
loading={trimmingLoading}
/>
)}
{/* Telegram Settings Modal */}
{showTelegramSettings && (
<TelegramSettings
onClose={() => setShowTelegramSettings(false)}
/>
)}
{/* Season Settings Modal */}
{showSeasonSettings && (
<SeasonSettingsComponent
onClose={() => setShowSeasonSettings(false)}
/>
)}
</div>
</div>
);
};

View File

@ -186,8 +186,44 @@ export const Users: React.FC<{ className?: string }> = ({ className = "h-6 w-6"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 3.13a4 4 0 010 7.75" />
</svg>
);
export const Zap: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<polygon points="13,2 3,14 12,14 11,22 21,10 12,10" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
export const Send: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<line x1="22" y1="2" x2="11" y2="13" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<polygon points="22,2 15,22 11,13 2,9" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
export const Snowflake: 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 2v20M17 7l-5 5 5 5M7 7l5 5-5 5" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2 12h20M7 17l5-5-5-5M17 17l-5-5 5-5" />
</svg>
);
export const Sun: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<line x1="12" y1="21" x2="12" y2="23" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<line x1="1" y1="12" x2="3" y2="12" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<line x1="21" y1="12" x2="23" y2="12" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
export const Settings: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
);

View File

@ -93,7 +93,7 @@ const MowingModal: React.FC<MowingModalProps> = ({
</h2>
{activityType === 'trimming' && (
<p className="text-sm text-gray-600 mt-1">
Подравнивание не приводит к изменению графика скашивания
Подравнивание сдвинет следующий покос на одну неделю
</p>
)}
</div>

View File

@ -0,0 +1,275 @@
import React, { useState, useEffect } from 'react';
import { X, Sun, Snowflake, Settings } from './Icons';
import { SeasonSettings } from '../types/zone';
import { api } from '../services/api';
interface SeasonSettingsProps {
onClose: () => void;
}
const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) => {
const [settings, setSettings] = useState<SeasonSettings>({
startMonth: 5,
startDay: 1,
endMonth: 9,
endDay: 30,
isActive: true,
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
const data = await api.getSeasonSettings();
setSettings(data);
} catch (error) {
console.error('Failed to load season settings:', error);
// Use default settings if none exist
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
setError(null);
// Validation
if (settings.startMonth < 1 || settings.startMonth > 12 ||
settings.endMonth < 1 || settings.endMonth > 12) {
setError('Months must be between 1 and 12');
setSaving(false);
return;
}
if (settings.startDay < 1 || settings.startDay > 31 ||
settings.endDay < 1 || settings.endDay > 31) {
setError('Days must be between 1 and 31');
setSaving(false);
return;
}
try {
await api.updateSeasonSettings(settings);
onClose();
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to save settings');
} finally {
setSaving(false);
}
};
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const formatDate = (month: number, day: number) => {
return `${monthNames[month - 1]} ${day}`;
};
const isCurrentlyInSeason = () => {
const now = new Date();
const currentMonth = now.getMonth() + 1;
const currentDay = now.getDate();
const startDate = new Date(now.getFullYear(), settings.startMonth - 1, settings.startDay);
const endDate = new Date(now.getFullYear(), settings.endMonth - 1, settings.endDay);
const currentDate = new Date(now.getFullYear(), currentMonth - 1, currentDay);
if (startDate <= endDate) {
// Same year season
return currentDate >= startDate && currentDate <= endDate;
} else {
// Cross-year season (e.g., Nov to Mar)
return currentDate >= startDate || currentDate <= endDate;
}
};
if (loading) {
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 p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600 mx-auto"></div>
</div>
</div>
);
}
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 flex items-center gap-2">
<Settings className="h-6 w-6 text-green-600" />
Mowing Season Settings
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors duration-200"
>
<X className="h-6 w-6" />
</button>
</div>
<div className="p-6 space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
{/* Season Status */}
<div className={`p-4 rounded-lg border ${
settings.isActive && isCurrentlyInSeason()
? 'bg-green-50 border-green-200'
: 'bg-gray-50 border-gray-200'
}`}>
<div className="flex items-center gap-3">
{settings.isActive && isCurrentlyInSeason() ? (
<Sun className="h-6 w-6 text-green-600" />
) : (
<Snowflake className="h-6 w-6 text-gray-600" />
)}
<div>
<h3 className="font-medium text-gray-900">
{settings.isActive && isCurrentlyInSeason() ? 'In Season' : 'Off Season'}
</h3>
<p className="text-sm text-gray-600">
{settings.isActive && isCurrentlyInSeason()
? 'Mowing schedules are active'
: 'All zones are considered maintained'
}
</p>
</div>
</div>
</div>
{/* Enable/Disable Season */}
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={settings.isActive}
onChange={(e) => setSettings(prev => ({ ...prev, isActive: e.target.checked }))}
className="mr-3 text-green-600 focus:ring-green-500"
/>
<span className="text-sm font-medium text-gray-700">
Enable mowing season (disable to treat all zones as maintained year-round)
</span>
</label>
</div>
{settings.isActive && (
<>
{/* Season Start */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Season Start
</label>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-500 mb-1">Month</label>
<select
value={settings.startMonth}
onChange={(e) => setSettings(prev => ({ ...prev, startMonth: parseInt(e.target.value) }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
{monthNames.map((month, index) => (
<option key={index} value={index + 1}>{month}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Day</label>
<input
type="number"
min="1"
max="31"
value={settings.startDay}
onChange={(e) => setSettings(prev => ({ ...prev, startDay: parseInt(e.target.value) || 1 }))}
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"
/>
</div>
</div>
<p className="text-xs text-gray-500 mt-1">
Season starts: {formatDate(settings.startMonth, settings.startDay)}
</p>
</div>
{/* Season End */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Season End
</label>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-500 mb-1">Month</label>
<select
value={settings.endMonth}
onChange={(e) => setSettings(prev => ({ ...prev, endMonth: parseInt(e.target.value) }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
{monthNames.map((month, index) => (
<option key={index} value={index + 1}>{month}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Day</label>
<input
type="number"
min="1"
max="31"
value={settings.endDay}
onChange={(e) => setSettings(prev => ({ ...prev, endDay: parseInt(e.target.value) || 1 }))}
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"
/>
</div>
</div>
<p className="text-xs text-gray-500 mt-1">
Season ends: {formatDate(settings.endMonth, settings.endDay)}
</p>
</div>
{/* Season Info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-sm font-medium text-blue-900 mb-2">How it works:</h4>
<ul className="text-sm text-blue-800 space-y-1">
<li> During season: Normal mowing schedules apply</li>
<li> Off season: All zones show as "maintained"</li>
<li> Mowing/trimming can still be recorded year-round</li>
<li> Notifications only sent during active season</li>
</ul>
</div>
</>
)}
</div>
<div className="flex gap-3 p-6 border-t">
<button
type="button"
onClick={onClose}
className="flex-1 py-2 px-4 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors duration-200"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
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"
>
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
</div>
);
};
export default SeasonSettingsComponent;

View File

@ -0,0 +1,236 @@
import React, { useState } from 'react';
import { X, Send, CheckCircle, AlertTriangle } from './Icons';
interface TelegramSettingsProps {
onClose: () => void;
}
const TelegramSettings: React.FC<TelegramSettingsProps> = ({ onClose }) => {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const handleTestConnection = async () => {
setTesting(true);
setTestResult(null);
try {
const response = await fetch('/api/telegram/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
const result = await response.json();
setTestResult(result);
} catch (error) {
setTestResult({
success: false,
message: 'Failed to test connection: ' + (error instanceof Error ? error.message : 'Unknown error')
});
} finally {
setTesting(false);
}
};
const handleSendTestReminder = async () => {
setTesting(true);
try {
const response = await fetch('/api/telegram/check-reminders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
const result = await response.json();
setTestResult(result);
} catch (error) {
setTestResult({
success: false,
message: 'Failed to send reminder: ' + (error instanceof Error ? error.message : 'Unknown error')
});
} finally {
setTesting(false);
}
};
const handleSendWeeklyReport = async () => {
setTesting(true);
try {
const response = await fetch('/api/telegram/weekly-report', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
const result = await response.json();
setTestResult(result);
} catch (error) {
setTestResult({
success: false,
message: 'Failed to send report: ' + (error instanceof Error ? error.message : 'Unknown error')
});
} finally {
setTesting(false);
}
};
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-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<Send className="h-6 w-6 text-blue-600" />
Telegram Notifications
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors duration-200"
>
<X className="h-6 w-6" />
</button>
</div>
<div className="p-6 space-y-6">
{/* Setup Instructions */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-lg font-medium text-blue-900 mb-3">Setup Instructions</h3>
<ol className="list-decimal list-inside space-y-2 text-sm text-blue-800">
<li>Create a new bot by messaging <code className="bg-blue-100 px-1 rounded">@BotFather</code> on Telegram</li>
<li>Send <code className="bg-blue-100 px-1 rounded">/newbot</code> and follow the instructions</li>
<li>Copy the bot token and add it to your <code className="bg-blue-100 px-1 rounded">.env</code> file as <code className="bg-blue-100 px-1 rounded">TELEGRAM_BOT_TOKEN</code></li>
<li>Start a chat with your bot and send any message</li>
<li>Visit <code className="bg-blue-100 px-1 rounded text-xs">https://api.telegram.org/bot&lt;YOUR_BOT_TOKEN&gt;/getUpdates</code> to get your chat ID</li>
<li>Add the chat ID to your <code className="bg-blue-100 px-1 rounded">.env</code> file as <code className="bg-blue-100 px-1 rounded">TELEGRAM_CHAT_ID</code></li>
<li>Restart the server to apply the changes</li>
</ol>
</div>
{/* Notification Schedule */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h3 className="text-lg font-medium text-gray-900 mb-3">Notification Schedule</h3>
<div className="space-y-2 text-sm text-gray-700">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-orange-500 rounded-full"></div>
<span><strong>Daily Reminders:</strong> 8:00 AM - Zones that are due or overdue</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span><strong>Weekly Report:</strong> Sunday 9:00 AM - Complete lawn status summary</span>
</div>
</div>
</div>
{/* Test Buttons */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Test Notifications</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<button
onClick={handleTestConnection}
disabled={testing}
className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
<Send className="h-4 w-4" />
{testing ? 'Testing...' : 'Test Connection'}
</button>
<button
onClick={handleSendTestReminder}
disabled={testing}
className="flex items-center justify-center gap-2 px-4 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
<AlertTriangle className="h-4 w-4" />
{testing ? 'Sending...' : 'Send Reminder'}
</button>
<button
onClick={handleSendWeeklyReport}
disabled={testing}
className="flex items-center justify-center gap-2 px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
<CheckCircle className="h-4 w-4" />
{testing ? 'Sending...' : 'Send Report'}
</button>
</div>
</div>
{/* Test Result */}
{testResult && (
<div className={`p-4 rounded-lg border ${
testResult.success
? 'bg-green-50 border-green-200 text-green-800'
: 'bg-red-50 border-red-200 text-red-800'
}`}>
<div className="flex items-center gap-2">
{testResult.success ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : (
<AlertTriangle className="h-5 w-5 text-red-600" />
)}
<span className="font-medium">
{testResult.success ? 'Success!' : 'Error'}
</span>
</div>
<p className="mt-1 text-sm">{testResult.message}</p>
</div>
)}
{/* Example Messages */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h3 className="text-lg font-medium text-gray-900 mb-3">Example Messages</h3>
<div className="space-y-4">
<div>
<h4 className="font-medium text-gray-800 mb-2">Daily Reminder:</h4>
<div className="bg-white border rounded p-3 text-sm font-mono">
🌱 <strong>Lawn Mowing Reminder</strong><br/><br/>
🚨 <strong>OVERDUE ZONES:</strong><br/>
Front Yard - 2 days overdue<br/><br/>
<strong>DUE TODAY:</strong><br/>
Back Garden<br/><br/>
Total zones needing attention: 2
</div>
</div>
<div>
<h4 className="font-medium text-gray-800 mb-2">Weekly Report:</h4>
<div className="bg-white border rounded p-3 text-sm font-mono">
📊 <strong>Weekly Lawn Care Report</strong><br/>
📅 Sunday, January 21, 2024<br/><br/>
🏡 <strong>ZONE STATUS:</strong><br/>
Up to date: 2 zones<br/>
🆕 New zones: 0 zones<br/>
Due today: 1 zones<br/>
🚨 Overdue: 0 zones<br/>
📐 Total area: 580 sq ft<br/>
🌱 Mowed area: 85.2%<br/><br/>
📈 <strong>THIS WEEK'S ACTIVITY:</strong><br/>
🔄 Mowing sessions: 3<br/>
Total time: 2h 15m<br/>
📐 Area mowed: 1,160 sq ft<br/>
🎯 Zones maintained: 3
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-end p-6 border-t">
<button
onClick={onClose}
className="px-6 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors duration-200"
>
Close
</button>
</div>
</div>
</div>
);
};
export default TelegramSettings;

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Scissors, Edit, Trash2, Calendar, Camera, Square, Clock, AlertTriangle, Zap } from './Icons';
import { Zone } from '../types/zone';
import {ImagePreview} from "./utils/ImagePreview.tsx";
interface ZoneCardProps {
zone: Zone;
@ -28,6 +29,8 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
return 'border-orange-500 bg-orange-50';
case 'new':
return 'border-blue-500 bg-blue-50';
case 'off-season':
return 'border-gray-500 bg-gray-50';
default:
return 'border-green-500 bg-green-50';
}
@ -36,6 +39,8 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
const getStatusText = (zone: Zone) => {
if (zone.isNew) {
return 'Еще не косилась';
} else if (zone.isOffSeason) {
return 'Не сезон';
} else if (zone.isOverdue) {
return `${Math.abs(zone.daysUntilNext!)} дней посроченно`;
} else if (zone.isDueToday) {
@ -53,6 +58,8 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
return 'text-orange-700';
case 'new':
return 'text-blue-700';
case 'off-season':
return 'text-gray-700';
default:
return 'text-green-700';
}
@ -101,12 +108,11 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
{/* Zone Image */}
{zone.imagePath ? (
<div className="relative h-48 overflow-hidden rounded-t-lg">
<img
src={`${API_BASE}${zone.imagePath}`}
alt={zone.name}
className="w-full h-full object-cover"
<ImagePreview
thumbnailSrc={`${API_BASE}${zone.imagePath}`}
fullSrc={`${API_BASE}${zone.imagePath}`}
alt={zone.name}
/>
<div className="absolute inset-0 bg-black bg-opacity-20"></div>
</div>
) : (
<div className="h-48 bg-gray-100 rounded-t-lg flex items-center justify-center">
@ -148,9 +154,9 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
<div className="flex gap-2">
<button
onClick={() => onMarkAsMowed(zone)}
disabled={zone.status === 'ok' && zone.daysUntilNext! > 1}
disabled={(zone.status === 'ok' && zone.daysUntilNext! > 1) || zone.isOffSeason}
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) || zone.isOffSeason
? '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'
@ -158,7 +164,7 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
}`}
>
<Scissors className="h-4 w-4 inline mr-1" />
{zone.isNew ? 'Первый покос' : 'Скошенно'}
{zone.isOffSeason ? 'Off Season' : zone.isNew ? 'Первый покос' : 'Скошенно'}
</button>
<button

View File

@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
interface ImagePreviewProps {
thumbnailSrc: string;
fullSrc: string;
alt?: string;
}
export const ImagePreview: React.FC<ImagePreviewProps> = ({ thumbnailSrc, fullSrc, alt }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
{/* Миниатюра */}
<motion.img
src={thumbnailSrc}
alt={alt || ''}
className="object-cover cursor-pointer rounded shadow"
whileHover={{ scale: 1.05 }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
onClick={() => setIsOpen(true)}
/>
{/* Модалка */}
<AnimatePresence>
{isOpen && (
<motion.div
className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50"
onClick={() => setIsOpen(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.img
src={fullSrc}
alt={alt || ''}
className="max-w-[90%] max-h-[90%] rounded shadow-lg"
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.5, opacity: 0 }}
transition={{ duration: 0.3 }}
onClick={(e) => e.stopPropagation()}
/>
</motion.div>
)}
</AnimatePresence>
</>
);
};

View File

@ -1,4 +1,14 @@
import { Zone, ZoneFormData, MowingHistoryResponse, MowingStats, MowingFormData, Mower, MowerFormData, BulkMowingFormData } from '../types/zone';
import {
Zone,
ZoneFormData,
MowingHistoryResponse,
MowingStats,
MowingFormData,
Mower,
MowerFormData,
BulkMowingFormData,
SeasonSettings
} from '../types/zone';
const APP_MODE = import.meta.env.VITE_ENV;
const API_BASE = APP_MODE == "development" ? new URL('api', import.meta.env.VITE_API_URL).toString() : import.meta.env.VITE_API_URL;
@ -148,4 +158,23 @@ export const api = {
if (!response.ok) throw new Error('Failed to create mower');
return response.json();
},
async getSeasonSettings(): Promise<SeasonSettings> {
const response = await fetch(`${API_BASE}/season`);
if (!response.ok) throw new Error('Failed to fetch season settings');
return response.json();
},
async updateSeasonSettings(data: SeasonSettings): Promise<SeasonSettings> {
const response = await fetch(`${API_BASE}/season`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to update season settings');
return response.json();
},
};

View File

@ -10,10 +10,11 @@ export interface Zone {
createdAt: string;
daysSinceLastMow?: number;
daysUntilNext?: number;
status: 'ok' | 'due' | 'overdue' | 'new';
status: 'ok' | 'due' | 'overdue' | 'new' | 'off-season';
isOverdue: boolean;
isDueToday: boolean;
isNew: boolean;
isOffSeason?: boolean;
}
export interface ZoneFormData {
@ -36,6 +37,17 @@ export interface Mower {
createdAt: string;
}
export interface SeasonSettings {
id?: number;
startMonth: number; // 1-12
startDay: number; // 1-31
endMonth: number; // 1-12
endDay: number; // 1-31
isActive: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface MowerFormData {
name: string;
type: string;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

View File

@ -21,5 +21,9 @@ export default defineConfig(({ mode }) => {
},
},
},
define: {
'process.env.TELEGRAM_BOT_TOKEN': JSON.stringify(env.TELEGRAM_BOT_TOKEN),
'process.env.TELEGRAM_CHAT_ID': JSON.stringify(env.TELEGRAM_CHAT_ID),
},
}
});