Версия 0.7.0 Добавлены сезон кошения. При подравнивании срок кошения сдвигается на неделю.
This commit is contained in:
parent
0e4e9b364d
commit
f27c964ecc
@ -1,2 +1,13 @@
|
|||||||
VITE_API_URL="http://localhost:3001"
|
VITE_API_URL="http://localhost:3001"
|
||||||
VITE_ENV="development"
|
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"
|
||||||
|
|
||||||
|
@ -1,2 +1,11 @@
|
|||||||
VITE_API_URL=/api
|
VITE_API_URL=/api
|
||||||
VITE_ENV=docker
|
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
|
||||||
|
|
||||||
|
@ -1,2 +1,10 @@
|
|||||||
VITE_API_URL=/api
|
VITE_API_URL=/api
|
||||||
VITE_ENV=production
|
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
182
README.md
Normal 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
0
lawn_scheduler.db
Normal file
1963
package-lock.json
generated
1963
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "lawn-mowing-scheduler",
|
"name": "lawn-mowing-scheduler",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.2",
|
"version": "0.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
||||||
@ -16,9 +16,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@libsql/client": "^0.4.0",
|
"@libsql/client": "^0.4.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"framer-motion": "^12.23.12",
|
||||||
"lucide-react": "^0.344.0",
|
"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": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.0"
|
"react-router-dom": "^6.26.0"
|
||||||
|
331
server/index.js
331
server/index.js
@ -5,6 +5,13 @@ import multer from 'multer';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import fs from 'fs';
|
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 __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
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
|
// Add new columns to existing zones table if they don't exist
|
||||||
try {
|
try {
|
||||||
await db.execute(`ALTER TABLE zones ADD COLUMN nextMowDate TEXT`);
|
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
|
// 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();
|
const today = new Date();
|
||||||
|
|
||||||
// For new zones that haven't been mowed yet
|
// For new zones that haven't been mowed yet
|
||||||
@ -231,7 +309,8 @@ function calculateZoneStatus(zone) {
|
|||||||
status: 'new',
|
status: 'new',
|
||||||
isOverdue: false,
|
isOverdue: false,
|
||||||
isDueToday: false,
|
isDueToday: false,
|
||||||
isNew: true
|
isNew: true,
|
||||||
|
isOffSeason: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -240,32 +319,37 @@ function calculateZoneStatus(zone) {
|
|||||||
|
|
||||||
let daysUntilNext;
|
let daysUntilNext;
|
||||||
let status = 'ok';
|
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
|
// Specific date scheduling
|
||||||
const nextMowDate = new Date(zone.nextMowDate);
|
const nextMowDate = new Date(zone.nextMowDate);
|
||||||
daysUntilNext = Math.floor((nextMowDate - today) / (1000 * 60 * 60 * 24));
|
daysUntilNext = Math.floor((nextMowDate - today) / (1000 * 60 * 60 * 24));
|
||||||
} else {
|
} else if (zone.scheduleType === 'interval' && zone.intervalDays) {
|
||||||
// Interval-based scheduling
|
// Interval-based scheduling
|
||||||
daysUntilNext = zone.intervalDays - daysSinceLastMow;
|
daysUntilNext = zone.intervalDays - daysSinceLastMow;
|
||||||
|
} else {
|
||||||
|
// No valid scheduling information
|
||||||
|
daysUntilNext = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (daysUntilNext < 0) {
|
if (daysUntilNext !== null && daysUntilNext < 0) {
|
||||||
status = 'overdue';
|
status = 'overdue';
|
||||||
} else if (daysUntilNext <= 0) {
|
} else if (daysUntilNext !== null && daysUntilNext <= 0) {
|
||||||
status = 'due';
|
status = 'due';
|
||||||
} else if (daysUntilNext <= 1) {
|
} else if (daysUntilNext !== null && daysUntilNext <= 1) {
|
||||||
status = 'due';
|
status = 'due';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...zone,
|
...zone,
|
||||||
daysSinceLastMow,
|
daysSinceLastMow,
|
||||||
daysUntilNext,
|
daysUntilNext,
|
||||||
status,
|
status,
|
||||||
isOverdue: daysUntilNext < 0,
|
isOverdue: daysUntilNext !== null && daysUntilNext < 0,
|
||||||
isDueToday: daysUntilNext <= 0 && daysUntilNext >= -1,
|
isDueToday: daysUntilNext !== null && daysUntilNext <= 0 && daysUntilNext >= -1,
|
||||||
isNew: false
|
isNew: false,
|
||||||
|
isOffSeason: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -346,7 +430,7 @@ app.get('/api/zones', async (req, res) => {
|
|||||||
area: row.area || 0,
|
area: row.area || 0,
|
||||||
createdAt: row.createdAt
|
createdAt: row.createdAt
|
||||||
}));
|
}));
|
||||||
const zonesWithStatus = zones.map(calculateZoneStatus);
|
const zonesWithStatus = await Promise.all(zones.map(calculateZoneStatus));
|
||||||
res.json(zonesWithStatus);
|
res.json(zonesWithStatus);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
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,
|
area: updatedResult.rows[0].area || 0,
|
||||||
createdAt: updatedResult.rows[0].createdAt
|
createdAt: updatedResult.rows[0].createdAt
|
||||||
};
|
};
|
||||||
|
|
||||||
const zoneWithStatus = calculateZoneStatus(updatedZone);
|
const zoneWithStatus = await calculateZoneStatus(zone);
|
||||||
res.json(zoneWithStatus);
|
res.json(zoneWithStatus);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
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,
|
area: updatedResult.rows[0].area || 0,
|
||||||
createdAt: updatedResult.rows[0].createdAt
|
createdAt: updatedResult.rows[0].createdAt
|
||||||
};
|
};
|
||||||
|
|
||||||
const zoneWithStatus = calculateZoneStatus(updatedZone);
|
const zoneWithStatus = await calculateZoneStatus(zone);
|
||||||
res.json(zoneWithStatus);
|
res.json(zoneWithStatus);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
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) => {
|
app.post('/api/zones/:id/trim', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { notes, duration, weather, mowerId } = req.body;
|
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) {
|
if (zoneResult.rows.length === 0) {
|
||||||
return res.status(404).json({ error: 'Zone not found' });
|
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
|
// Add to mowing history with trimming activity type
|
||||||
// Note: We don't update the zone's lastMowedDate or nextMowDate for trimming
|
// Note: We don't update the zone's lastMowedDate or nextMowDate for trimming
|
||||||
await db.execute({
|
await db.execute({
|
||||||
sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather, activityType) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
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']
|
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) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
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 });
|
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
|
// Add to mowing history with session ID
|
||||||
@ -869,8 +1012,8 @@ app.post('/api/zones/bulk-mow', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Successfully recorded ${activityType} session for ${zones.length} zones`,
|
message: `Successfully recorded ${activityType} session for ${zones.length} zones${activityType === 'trimming' ? '. Next mowing dates have been delayed by one week.' : ''}`,
|
||||||
sessionId,
|
sessionId,
|
||||||
zonesUpdated: zones.length
|
zonesUpdated: zones.length
|
||||||
});
|
});
|
||||||
@ -891,7 +1034,147 @@ app.get('/health', (req, res) => {
|
|||||||
res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString() });
|
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', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Server running on http://0.0.0.0:${PORT}`);
|
console.log(`Server running on http://0.0.0.0:${PORT}`);
|
||||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
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
272
server/telegram.js
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
@ -152,7 +152,7 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
{activityType === 'trimming'
|
{activityType === 'trimming'
|
||||||
? 'Выберите несколько зон, обрезанных за один сеанс (не сбрасывает расписание скашивания).'
|
? 'Выберите несколько зон, обрезанных за один сеанс (сдвиг скашивания на одну неделю в каждой зоне).'
|
||||||
: 'Выберите несколько зон, скошенных за один сеанс'
|
: 'Выберите несколько зон, скошенных за один сеанс'
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
@ -203,7 +203,7 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
|
|||||||
{formData.selectedZoneIds.length > 0 && (
|
{formData.selectedZoneIds.length > 0 && (
|
||||||
<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>Selected:</strong> {selectedZones.map(z => z.name).join(', ')}
|
<strong>Выбрано:</strong> {selectedZones.map(z => z.name).join(', ')}
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
(Общая площадь: {totalSelectedArea.toLocaleString()} м2)
|
(Общая площадь: {totalSelectedArea.toLocaleString()} м2)
|
||||||
</span>
|
</span>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { Zone, MowingFormData, BulkMowingFormData } from '../types/zone';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
import ZoneCard from './ZoneCard';
|
import ZoneCard from './ZoneCard';
|
||||||
@ -8,6 +8,8 @@ import SitePlan from './SitePlan';
|
|||||||
import HistoryView from './HistoryView';
|
import HistoryView from './HistoryView';
|
||||||
import MowingModal from './MowingModal';
|
import MowingModal from './MowingModal';
|
||||||
import BulkMowingModal from './BulkMowingModal';
|
import BulkMowingModal from './BulkMowingModal';
|
||||||
|
import TelegramSettings from './TelegramSettings';
|
||||||
|
import SeasonSettingsComponent from './SeasonSettings';
|
||||||
|
|
||||||
type FilterType = 'all' | 'due' | 'overdue' | 'new';
|
type FilterType = 'all' | 'due' | 'overdue' | 'new';
|
||||||
type ViewType = 'dashboard' | 'sitePlan' | 'history';
|
type ViewType = 'dashboard' | 'sitePlan' | 'history';
|
||||||
@ -26,9 +28,12 @@ const Dashboard: React.FC = () => {
|
|||||||
const [showBulkTrimmingModal, setShowBulkTrimmingModal] = useState(false);
|
const [showBulkTrimmingModal, setShowBulkTrimmingModal] = useState(false);
|
||||||
const [mowingZone, setMowingZone] = useState<Zone | null>(null);
|
const [mowingZone, setMowingZone] = useState<Zone | null>(null);
|
||||||
const [trimmingZone, setTrimmingZone] = 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 [loading, setLoading] = useState(true);
|
||||||
const [mowingLoading, setMowingLoading] = useState(false);
|
const [mowingLoading, setMowingLoading] = useState(false);
|
||||||
const [trimmingLoading, setTrimmingLoading] = useState(false);
|
const [trimmingLoading, setTrimmingLoading] = useState(false);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadZones();
|
loadZones();
|
||||||
@ -94,13 +99,14 @@ const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
const handleTrimmingSubmit = async (data: MowingFormData) => {
|
const handleTrimmingSubmit = async (data: MowingFormData) => {
|
||||||
if (!trimmingZone) return;
|
if (!trimmingZone) return;
|
||||||
|
|
||||||
setTrimmingLoading(true);
|
setTrimmingLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.markAsTrimmed(trimmingZone.id, data);
|
await api.markAsTrimmed(trimmingZone.id, data);
|
||||||
setShowTrimmingModal(false);
|
setShowTrimmingModal(false);
|
||||||
setTrimmingZone(null);
|
setTrimmingZone(null);
|
||||||
// Note: Don't reload zones since trimming doesn't affect mowing schedule
|
// Перезагрузка зон, поскольку подравнивание теперь влияет на график скашивания (задержки на 1 неделю)
|
||||||
|
loadZones();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to mark as trimmed:', error);
|
console.error('Failed to mark as trimmed:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -125,7 +131,8 @@ const Dashboard: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await api.bulkMarkAsMowed(data);
|
await api.bulkMarkAsMowed(data);
|
||||||
setShowBulkTrimmingModal(false);
|
setShowBulkTrimmingModal(false);
|
||||||
// Note: Don't reload zones since trimming doesn't affect mowing schedule
|
// Перезагрузка зон, поскольку подравнивание теперь влияет на график скашивания (задержки на 1 неделю)
|
||||||
|
loadZones();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to record bulk trimming session:', error);
|
console.error('Failed to record bulk trimming session:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -170,12 +177,12 @@ const Dashboard: React.FC = () => {
|
|||||||
const newCount = zones.filter(zone => zone.isNew).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);
|
||||||
|
|
||||||
// Calculate mowed vs remaining area based on status
|
// Calculate mowed vs remaining area based on status
|
||||||
const mowedArea = zones
|
const mowedArea = zones
|
||||||
.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 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
|
// Check if there are zones that need mowing for bulk action
|
||||||
@ -185,299 +192,426 @@ const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
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">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<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">
|
<div className="flex h-screen">
|
||||||
{/* Header */}
|
{/* Mobile sidebar overlay */}
|
||||||
<div className="mb-8">
|
{sidebarOpen && (
|
||||||
<div className="flex items-center justify-between">
|
<div
|
||||||
<div>
|
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
|
||||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
|
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" />
|
<Scissors className="h-8 w-8 text-green-600" />
|
||||||
Менеджер по уходу за газоном
|
<h1 className="text-xl font-bold text-gray-900">Уход за газоном</h1>
|
||||||
</h1>
|
</div>
|
||||||
<p className="mt-2 text-gray-600">Следите за своим графиком стрижки газона</p>
|
<button
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className="lg:hidden text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* View Toggle */}
|
<nav className="mt-6 px-3">
|
||||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
<div className="space-y-1">
|
||||||
|
{/* Navigation Items */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('dashboard')}
|
onClick={() => {
|
||||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
|
setView('dashboard');
|
||||||
view === 'dashboard'
|
setSidebarOpen(false);
|
||||||
? 'bg-white text-gray-900 shadow-sm'
|
}}
|
||||||
: 'text-gray-600 hover:text-gray-900'
|
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'
|
||||||
<Calendar className="h-4 w-4 inline mr-2" />
|
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||||
Панель
|
|
||||||
</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'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => setShowForm(true)}
|
<div className="mt-8">
|
||||||
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"
|
<h3 className="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||||
>
|
Действия
|
||||||
<Plus className="h-5 w-5" />
|
</h3>
|
||||||
Новая зона
|
<div className="mt-2 space-y-1">
|
||||||
</button>
|
<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>
|
</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 */}
|
{/* Zone Form Modal */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<ZoneForm
|
<ZoneForm
|
||||||
zone={editingZone}
|
zone={editingZone}
|
||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingZone(null);
|
setEditingZone(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Single Zone Mowing Modal */}
|
{/* Single Zone Mowing Modal */}
|
||||||
@ -496,16 +630,16 @@ const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
{/* Single Zone Trimming Modal */}
|
{/* Single Zone Trimming Modal */}
|
||||||
{showTrimmingModal && trimmingZone && (
|
{showTrimmingModal && trimmingZone && (
|
||||||
<MowingModal
|
<MowingModal
|
||||||
zoneName={trimmingZone.name}
|
zoneName={trimmingZone.name}
|
||||||
activityType="trimming"
|
activityType="trimming"
|
||||||
onSubmit={handleTrimmingSubmit}
|
onSubmit={handleTrimmingSubmit}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setShowTrimmingModal(false);
|
setShowTrimmingModal(false);
|
||||||
setTrimmingZone(null);
|
setTrimmingZone(null);
|
||||||
}}
|
}}
|
||||||
loading={trimmingLoading}
|
loading={trimmingLoading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Bulk Mowing Modal */}
|
{/* Bulk Mowing Modal */}
|
||||||
{showBulkMowingModal && (
|
{showBulkMowingModal && (
|
||||||
@ -520,16 +654,29 @@ const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
{/* Bulk Trimming Modal */}
|
{/* Bulk Trimming Modal */}
|
||||||
{showBulkTrimmingModal && (
|
{showBulkTrimmingModal && (
|
||||||
<BulkMowingModal
|
<BulkMowingModal
|
||||||
zones={zones}
|
zones={zones}
|
||||||
activityType="trimming"
|
activityType="trimming"
|
||||||
onSubmit={handleBulkTrimmingSubmit}
|
onSubmit={handleBulkTrimmingSubmit}
|
||||||
onCancel={() => setShowBulkTrimmingModal(false)}
|
onCancel={() => setShowBulkTrimmingModal(false)}
|
||||||
loading={trimmingLoading}
|
loading={trimmingLoading}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Telegram Settings Modal */}
|
||||||
|
{showTelegramSettings && (
|
||||||
|
<TelegramSettings
|
||||||
|
onClose={() => setShowTelegramSettings(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Season Settings Modal */}
|
||||||
|
{showSeasonSettings && (
|
||||||
|
<SeasonSettingsComponent
|
||||||
|
onClose={() => setShowSeasonSettings(false)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 3.13a4 4 0 010 7.75" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Zap: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
export const Zap: 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="13,2 3,14 12,14 11,22 21,10 12,10" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
<polygon points="13,2 3,14 12,14 11,22 21,10 12,10" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
</svg>
|
</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>
|
||||||
);
|
);
|
@ -93,7 +93,7 @@ const MowingModal: React.FC<MowingModalProps> = ({
|
|||||||
</h2>
|
</h2>
|
||||||
{activityType === 'trimming' && (
|
{activityType === 'trimming' && (
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
Подравнивание не приводит к изменению графика скашивания
|
Подравнивание сдвинет следующий покос на одну неделю
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
275
src/components/SeasonSettings.tsx
Normal file
275
src/components/SeasonSettings.tsx
Normal 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;
|
236
src/components/TelegramSettings.tsx
Normal file
236
src/components/TelegramSettings.tsx
Normal 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<YOUR_BOT_TOKEN>/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;
|
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Scissors, Edit, Trash2, Calendar, Camera, Square, Clock, AlertTriangle, Zap } from './Icons';
|
import { Scissors, Edit, Trash2, Calendar, Camera, Square, Clock, AlertTriangle, Zap } from './Icons';
|
||||||
import { Zone } from '../types/zone';
|
import { Zone } from '../types/zone';
|
||||||
|
import {ImagePreview} from "./utils/ImagePreview.tsx";
|
||||||
|
|
||||||
interface ZoneCardProps {
|
interface ZoneCardProps {
|
||||||
zone: Zone;
|
zone: Zone;
|
||||||
@ -28,6 +29,8 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
|||||||
return 'border-orange-500 bg-orange-50';
|
return 'border-orange-500 bg-orange-50';
|
||||||
case 'new':
|
case 'new':
|
||||||
return 'border-blue-500 bg-blue-50';
|
return 'border-blue-500 bg-blue-50';
|
||||||
|
case 'off-season':
|
||||||
|
return 'border-gray-500 bg-gray-50';
|
||||||
default:
|
default:
|
||||||
return 'border-green-500 bg-green-50';
|
return 'border-green-500 bg-green-50';
|
||||||
}
|
}
|
||||||
@ -36,6 +39,8 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
|||||||
const getStatusText = (zone: Zone) => {
|
const getStatusText = (zone: Zone) => {
|
||||||
if (zone.isNew) {
|
if (zone.isNew) {
|
||||||
return 'Еще не косилась';
|
return 'Еще не косилась';
|
||||||
|
} else if (zone.isOffSeason) {
|
||||||
|
return 'Не сезон';
|
||||||
} else if (zone.isOverdue) {
|
} else if (zone.isOverdue) {
|
||||||
return `${Math.abs(zone.daysUntilNext!)} дней посроченно`;
|
return `${Math.abs(zone.daysUntilNext!)} дней посроченно`;
|
||||||
} else if (zone.isDueToday) {
|
} else if (zone.isDueToday) {
|
||||||
@ -53,6 +58,8 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
|||||||
return 'text-orange-700';
|
return 'text-orange-700';
|
||||||
case 'new':
|
case 'new':
|
||||||
return 'text-blue-700';
|
return 'text-blue-700';
|
||||||
|
case 'off-season':
|
||||||
|
return 'text-gray-700';
|
||||||
default:
|
default:
|
||||||
return 'text-green-700';
|
return 'text-green-700';
|
||||||
}
|
}
|
||||||
@ -101,12 +108,11 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
|||||||
{/* Zone Image */}
|
{/* Zone Image */}
|
||||||
{zone.imagePath ? (
|
{zone.imagePath ? (
|
||||||
<div className="relative h-48 overflow-hidden rounded-t-lg">
|
<div className="relative h-48 overflow-hidden rounded-t-lg">
|
||||||
<img
|
<ImagePreview
|
||||||
src={`${API_BASE}${zone.imagePath}`}
|
thumbnailSrc={`${API_BASE}${zone.imagePath}`}
|
||||||
alt={zone.name}
|
fullSrc={`${API_BASE}${zone.imagePath}`}
|
||||||
className="w-full h-full object-cover"
|
alt={zone.name}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black bg-opacity-20"></div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
||||||
@ -148,9 +154,9 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onMarkAsMowed(zone)}
|
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 ${
|
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'
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
: zone.isNew
|
: zone.isNew
|
||||||
? 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm hover:shadow-md'
|
? '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" />
|
<Scissors className="h-4 w-4 inline mr-1" />
|
||||||
{zone.isNew ? 'Первый покос' : 'Скошенно'}
|
{zone.isOffSeason ? 'Off Season' : zone.isNew ? 'Первый покос' : 'Скошенно'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
52
src/components/utils/ImagePreview.tsx
Normal file
52
src/components/utils/ImagePreview.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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 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;
|
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');
|
if (!response.ok) throw new Error('Failed to create mower');
|
||||||
return response.json();
|
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();
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
@ -10,10 +10,11 @@ export interface Zone {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
daysSinceLastMow?: number;
|
daysSinceLastMow?: number;
|
||||||
daysUntilNext?: number;
|
daysUntilNext?: number;
|
||||||
status: 'ok' | 'due' | 'overdue' | 'new';
|
status: 'ok' | 'due' | 'overdue' | 'new' | 'off-season';
|
||||||
isOverdue: boolean;
|
isOverdue: boolean;
|
||||||
isDueToday: boolean;
|
isDueToday: boolean;
|
||||||
isNew: boolean;
|
isNew: boolean;
|
||||||
|
isOffSeason?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ZoneFormData {
|
export interface ZoneFormData {
|
||||||
@ -36,6 +37,17 @@ export interface Mower {
|
|||||||
createdAt: string;
|
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 {
|
export interface MowerFormData {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 2.2 MiB |
BIN
uploads/image-1752520984015-663619457.png
Normal file
BIN
uploads/image-1752520984015-663619457.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 42 KiB |
BIN
uploads/image-1755633340333-439590147.jpg
Normal file
BIN
uploads/image-1755633340333-439590147.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 411 KiB |
BIN
uploads/image-1755633358436-514471978.jpg
Normal file
BIN
uploads/image-1755633358436-514471978.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 438 KiB |
BIN
uploads/image-1755633375830-861582540.jpg
Normal file
BIN
uploads/image-1755633375830-861582540.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 346 KiB |
BIN
uploads/image-1755633472845-556577899.jpg
Normal file
BIN
uploads/image-1755633472845-556577899.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 432 KiB |
@ -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),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
});
|
});
|
Loading…
x
Reference in New Issue
Block a user