Версия 0.2.0. Добавлены сессии подравнивания. Исправлены файлы развертывания в Docker.
This commit is contained in:
parent
948b2a9f62
commit
b18e0d847e
@ -1,26 +1,21 @@
|
|||||||
# Development Dockerfile
|
# Используем официальный образ Node.js
|
||||||
FROM node:18-alpine
|
FROM node:20
|
||||||
|
|
||||||
|
# Создаем рабочую директорию
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies
|
# Копируем package.json и устанавливаем зависимости
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
# Copy source code
|
# Копируем остальные файлы проекта
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create uploads directory
|
# Меняем владельца рабочей директории на пользователя node
|
||||||
RUN mkdir -p uploads
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
# Create non-root user
|
# Переключаемся на безопасного пользователя node
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
USER node
|
||||||
adduser -S nodejs -u 1001
|
|
||||||
|
|
||||||
# Change ownership of app directory
|
|
||||||
RUN chown -R nodejs:nodejs /app
|
|
||||||
USER nodejs
|
|
||||||
|
|
||||||
EXPOSE 3001 5173
|
|
||||||
|
|
||||||
|
# Запускаем dev-сервер
|
||||||
CMD ["npm", "run", "dev"]
|
CMD ["npm", "run", "dev"]
|
@ -8,8 +8,8 @@ services:
|
|||||||
container_name: lawn-scheduler-dev
|
container_name: lawn-scheduler-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001" # Backend API
|
||||||
- "5173:5173"
|
- "5173:5173" # Vite dev server
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
@ -21,11 +21,11 @@ services:
|
|||||||
- lawn-scheduler-dev-network
|
- lawn-scheduler-dev-network
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: webdevops/nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: lawn-scheduler-nginx-dev
|
container_name: lawn-scheduler-nginx-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80" # Внешний доступ к фронту через NGINX
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro
|
- ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro
|
||||||
- ./uploads:/var/www/uploads:ro
|
- ./uploads:/var/www/uploads:ro
|
||||||
|
Binary file not shown.
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "lawn-mowing-scheduler",
|
"name": "lawn-mowing-scheduler",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
||||||
|
@ -98,6 +98,7 @@ await db.execute(`
|
|||||||
notes TEXT,
|
notes TEXT,
|
||||||
duration INTEGER, -- in minutes
|
duration INTEGER, -- in minutes
|
||||||
weather TEXT,
|
weather TEXT,
|
||||||
|
activityType TEXT DEFAULT 'mowing', -- 'mowing' or 'trimming'
|
||||||
sessionId TEXT, -- for grouping bulk mowing sessions
|
sessionId TEXT, -- for grouping bulk mowing sessions
|
||||||
createdAt TEXT DEFAULT CURRENT_TIMESTAMP,
|
createdAt TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (zoneId) REFERENCES zones (id) ON DELETE CASCADE,
|
FOREIGN KEY (zoneId) REFERENCES zones (id) ON DELETE CASCADE,
|
||||||
@ -139,6 +140,13 @@ try {
|
|||||||
// Column already exists, ignore error
|
// Column already exists, ignore error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add activityType column to existing mowing_history table if it doesn't exist
|
||||||
|
try {
|
||||||
|
await db.execute(`ALTER TABLE mowing_history ADD COLUMN activityType TEXT DEFAULT 'mowing'`);
|
||||||
|
} catch (error) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
// Insert default mowers if table is empty
|
// Insert default mowers if table is empty
|
||||||
const mowerCountResult = await db.execute('SELECT COUNT(*) as count FROM mowers');
|
const mowerCountResult = await db.execute('SELECT COUNT(*) as count FROM mowers');
|
||||||
const mowerCount = mowerCountResult.rows[0].count;
|
const mowerCount = mowerCountResult.rows[0].count;
|
||||||
@ -433,6 +441,7 @@ app.get('/api/history', async (req, res) => {
|
|||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
duration: row.duration,
|
duration: row.duration,
|
||||||
weather: row.weather,
|
weather: row.weather,
|
||||||
|
activityType: row.activityType || 'mowing',
|
||||||
sessionId: row.sessionId,
|
sessionId: row.sessionId,
|
||||||
createdAt: row.createdAt
|
createdAt: row.createdAt
|
||||||
}));
|
}));
|
||||||
@ -733,8 +742,8 @@ app.post('/api/zones/:id/mow', async (req, res) => {
|
|||||||
|
|
||||||
// Add to mowing history
|
// Add to mowing history
|
||||||
await db.execute({
|
await db.execute({
|
||||||
sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather) 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]
|
args: [req.params.id, mowerId || null, today, notes || null, duration || null, weather || null, 'mowing']
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedResult = await db.execute({
|
const updatedResult = await db.execute({
|
||||||
@ -761,10 +770,39 @@ app.post('/api/zones/:id/mow', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Trimming endpoint (doesn't update zone schedule)
|
||||||
|
app.post('/api/zones/:id/trim', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { notes, duration, weather, mowerId } = req.body;
|
||||||
|
const today = new Date().toISOString();
|
||||||
|
|
||||||
|
// Get current zone data to verify it exists
|
||||||
|
const zoneResult = await db.execute({
|
||||||
|
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||||
|
args: [req.params.id]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (zoneResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Zone not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to mowing history with trimming activity type
|
||||||
|
// Note: We don't update the zone's lastMowedDate or nextMowDate for trimming
|
||||||
|
await db.execute({
|
||||||
|
sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather, activityType) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
args: [req.params.id, mowerId || null, today, notes || null, duration || null, weather || null, 'trimming']
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Trimming recorded successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Bulk mowing endpoint
|
// Bulk mowing endpoint
|
||||||
app.post('/api/zones/bulk-mow', async (req, res) => {
|
app.post('/api/zones/bulk-mow', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { selectedZoneIds, notes, totalDuration, weather, mowerId } = req.body;
|
const { selectedZoneIds, notes, totalDuration, weather, mowerId, activityType = 'mowing' } = req.body;
|
||||||
|
|
||||||
if (!selectedZoneIds || selectedZoneIds.length === 0) {
|
if (!selectedZoneIds || selectedZoneIds.length === 0) {
|
||||||
return res.status(400).json({ error: 'No zones selected' });
|
return res.status(400).json({ error: 'No zones selected' });
|
||||||
@ -801,6 +839,8 @@ app.post('/api/zones/bulk-mow', async (req, res) => {
|
|||||||
zoneDuration = Math.round(totalDuration * proportion);
|
zoneDuration = Math.round(totalDuration * proportion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only update zone schedule for mowing, not trimming
|
||||||
|
if (activityType === 'mowing') {
|
||||||
// Update zone's last mowed date and calculate next mow date if using interval scheduling
|
// Update zone's last mowed date and calculate next mow date if using interval scheduling
|
||||||
let sql, args;
|
let sql, args;
|
||||||
|
|
||||||
@ -815,17 +855,18 @@ app.post('/api/zones/bulk-mow', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await db.execute({ sql, args });
|
await db.execute({ sql, args });
|
||||||
|
}
|
||||||
|
|
||||||
// Add to mowing history with session ID
|
// Add to mowing history with session ID
|
||||||
await db.execute({
|
await db.execute({
|
||||||
sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather, sessionId) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather, sessionId, activityType) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
args: [zone.id, mowerId || null, today, notes || null, zoneDuration, weather || null, sessionId]
|
args: [zone.id, mowerId || null, today, notes || null, zoneDuration, weather || null, sessionId, activityType]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Successfully recorded mowing session for ${zones.length} zones`,
|
message: `Successfully recorded ${activityType} session for ${zones.length} zones`,
|
||||||
sessionId,
|
sessionId,
|
||||||
zonesUpdated: zones.length
|
zonesUpdated: zones.length
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { X, Timer, Cloud, FileText, Scissors, CheckCircle, Square } from './Icons';
|
import { X, Timer, Cloud, FileText, Scissors, CheckCircle, Square, Zap } from './Icons';
|
||||||
import { Zone, Mower, BulkMowingFormData } from '../types/zone';
|
import { Zone, Mower, BulkMowingFormData } from '../types/zone';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
|
|
||||||
interface BulkMowingModalProps {
|
interface BulkMowingModalProps {
|
||||||
zones: Zone[];
|
zones: Zone[];
|
||||||
|
activityType?: 'mowing' | 'trimming';
|
||||||
onSubmit: (data: BulkMowingFormData) => void;
|
onSubmit: (data: BulkMowingFormData) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@ -12,6 +13,7 @@ interface BulkMowingModalProps {
|
|||||||
|
|
||||||
const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
|
const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
|
||||||
zones,
|
zones,
|
||||||
|
activityType = 'mowing',
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
loading = false
|
loading = false
|
||||||
@ -22,6 +24,7 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
|
|||||||
totalDuration: undefined,
|
totalDuration: undefined,
|
||||||
weather: '',
|
weather: '',
|
||||||
mowerId: undefined,
|
mowerId: undefined,
|
||||||
|
activityType,
|
||||||
});
|
});
|
||||||
const [mowers, setMowers] = useState<Mower[]>([]);
|
const [mowers, setMowers] = useState<Mower[]>([]);
|
||||||
const [loadingMowers, setLoadingMowers] = useState(true);
|
const [loadingMowers, setLoadingMowers] = useState(true);
|
||||||
@ -140,11 +143,18 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
|
|||||||
<div className="flex items-center justify-between p-6 border-b">
|
<div className="flex items-center justify-between p-6 border-b">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
{activityType === 'trimming' ? (
|
||||||
|
<Zap className="h-6 w-6 text-purple-600" />
|
||||||
|
) : (
|
||||||
<Scissors className="h-6 w-6 text-green-600" />
|
<Scissors className="h-6 w-6 text-green-600" />
|
||||||
Сеанс массового скашивания
|
)}
|
||||||
|
{activityType === 'trimming' ? 'Сеанс массового подравнивания' : 'Сеанс массового покоса'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
Выберите несколько зон, скошенных за один сеанс
|
{activityType === 'trimming'
|
||||||
|
? 'Выберите несколько зон, обрезанных за один сеанс (не сбрасывает расписание скашивания).'
|
||||||
|
: 'Выберите несколько зон, скошенных за один сеанс'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -207,8 +217,12 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
|
|||||||
{/* Mower Selection */}
|
{/* Mower Selection */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="mowerId" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="mowerId" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{activityType === 'trimming' ? (
|
||||||
|
<Zap className="h-4 w-4 inline mr-2" />
|
||||||
|
) : (
|
||||||
<Scissors className="h-4 w-4 inline mr-2" />
|
<Scissors className="h-4 w-4 inline mr-2" />
|
||||||
Использованная газонокосилка
|
)}
|
||||||
|
{activityType === 'trimming' ? 'Использованный триммер' : 'Использованная газонокосилка'}
|
||||||
</label>
|
</label>
|
||||||
{loadingMowers ? (
|
{loadingMowers ? (
|
||||||
<div className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50">
|
<div className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50">
|
||||||
@ -223,9 +237,11 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
|
|||||||
mowerId: e.target.value ? parseInt(e.target.value) : undefined
|
mowerId: e.target.value ? parseInt(e.target.value) : undefined
|
||||||
}))}
|
}))}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
required
|
required={activityType === 'mowing'}
|
||||||
>
|
>
|
||||||
<option value="">Выбор газонокосилки</option>
|
<option value="">
|
||||||
|
{activityType === 'trimming' ? 'Выбор триммера (не обязательно)' : 'Выбор газонокосилки'}
|
||||||
|
</option>
|
||||||
{mowers.map(mower => (
|
{mowers.map(mower => (
|
||||||
<option key={mower.id} value={mower.id}>
|
<option key={mower.id} value={mower.id}>
|
||||||
{getMowerIcon(mower.type)} {mower.name} ({mower.type})
|
{getMowerIcon(mower.type)} {mower.name} ({mower.type})
|
||||||
@ -252,7 +268,7 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
|
|||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
min="1"
|
min="1"
|
||||||
max="480"
|
max="480"
|
||||||
placeholder="напр., 90"
|
placeholder="например, 90"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Время будет распределено пропорционально площади зоны
|
Время будет распределено пропорционально площади зоны
|
||||||
@ -290,7 +306,11 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
|
|||||||
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Заметки о сеансе стрижки газона..."
|
placeholder={
|
||||||
|
activityType === 'trimming'
|
||||||
|
? "Заметки о сеансе подравнивания..."
|
||||||
|
: "Заметки о сеансе кошения..."
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -300,7 +320,7 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
|
|||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<h4 className="text-sm font-medium text-gray-900 mb-3 flex items-center gap-2">
|
<h4 className="text-sm font-medium text-gray-900 mb-3 flex items-center gap-2">
|
||||||
<Timer className="h-4 w-4" />
|
<Timer className="h-4 w-4" />
|
||||||
Предварительный просмотр распределения времени
|
{activityType === 'trimming' ? 'Trimming' : 'Mowing'} Time Distribution Preview
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{timeDistribution.map(({ zone, proportion, allocatedTime }) => (
|
{timeDistribution.map(({ zone, proportion, allocatedTime }) => (
|
||||||
@ -344,9 +364,16 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || formData.selectedZoneIds.length === 0}
|
disabled={loading || formData.selectedZoneIds.length === 0}
|
||||||
className="flex-1 py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
className={`flex-1 py-2 px-4 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ${
|
||||||
|
activityType === 'trimming'
|
||||||
|
? 'bg-purple-600 hover:bg-purple-700'
|
||||||
|
: 'bg-green-600 hover:bg-green-700'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{loading ? 'Запись сессии...' : `Запись покоса (${formData.selectedZoneIds.length} зон)`}
|
{loading
|
||||||
|
? 'Запись сессии...'
|
||||||
|
: `Запись ${activityType === 'trimming' ? 'Подравнивания' : 'Кошения'} сессии (${formData.selectedZoneIds.length} зон)`
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -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 } 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';
|
||||||
@ -7,7 +7,7 @@ import ZoneForm from './ZoneForm';
|
|||||||
import SitePlan from './SitePlan';
|
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';
|
||||||
|
|
||||||
type FilterType = 'all' | 'due' | 'overdue' | 'new';
|
type FilterType = 'all' | 'due' | 'overdue' | 'new';
|
||||||
type ViewType = 'dashboard' | 'sitePlan' | 'history';
|
type ViewType = 'dashboard' | 'sitePlan' | 'history';
|
||||||
@ -22,9 +22,13 @@ const Dashboard: React.FC = () => {
|
|||||||
const [selectedZoneId, setSelectedZoneId] = useState<number | undefined>();
|
const [selectedZoneId, setSelectedZoneId] = useState<number | undefined>();
|
||||||
const [showMowingModal, setShowMowingModal] = useState(false);
|
const [showMowingModal, setShowMowingModal] = useState(false);
|
||||||
const [showBulkMowingModal, setShowBulkMowingModal] = useState(false);
|
const [showBulkMowingModal, setShowBulkMowingModal] = useState(false);
|
||||||
|
const [showTrimmingModal, setShowTrimmingModal] = 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 [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [mowingLoading, setMowingLoading] = useState(false);
|
const [mowingLoading, setMowingLoading] = useState(false);
|
||||||
|
const [trimmingLoading, setTrimmingLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadZones();
|
loadZones();
|
||||||
@ -68,6 +72,10 @@ const Dashboard: React.FC = () => {
|
|||||||
setShowMowingModal(true);
|
setShowMowingModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMarkAsTrimmed = (zone: Zone) => {
|
||||||
|
setTrimmingZone(zone);
|
||||||
|
setShowTrimmingModal(true);
|
||||||
|
};
|
||||||
const handleMowingSubmit = async (data: MowingFormData) => {
|
const handleMowingSubmit = async (data: MowingFormData) => {
|
||||||
if (!mowingZone) return;
|
if (!mowingZone) return;
|
||||||
|
|
||||||
@ -84,6 +92,21 @@ const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTrimmingSubmit = async (data: MowingFormData) => {
|
||||||
|
if (!trimmingZone) return;
|
||||||
|
|
||||||
|
setTrimmingLoading(true);
|
||||||
|
try {
|
||||||
|
await api.markAsTrimmed(trimmingZone.id, data);
|
||||||
|
setShowTrimmingModal(false);
|
||||||
|
setTrimmingZone(null);
|
||||||
|
// Note: Don't reload zones since trimming doesn't affect mowing schedule
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mark as trimmed:', error);
|
||||||
|
} finally {
|
||||||
|
setTrimmingLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const handleBulkMowingSubmit = async (data: BulkMowingFormData) => {
|
const handleBulkMowingSubmit = async (data: BulkMowingFormData) => {
|
||||||
setMowingLoading(true);
|
setMowingLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -97,6 +120,18 @@ const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBulkTrimmingSubmit = async (data: BulkMowingFormData) => {
|
||||||
|
setTrimmingLoading(true);
|
||||||
|
try {
|
||||||
|
await api.bulkMarkAsMowed(data);
|
||||||
|
setShowBulkTrimmingModal(false);
|
||||||
|
// Note: Don't reload zones since trimming doesn't affect mowing schedule
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to record bulk trimming session:', error);
|
||||||
|
} finally {
|
||||||
|
setTrimmingLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const handleDeleteZone = async (id: number) => {
|
const handleDeleteZone = async (id: number) => {
|
||||||
if (window.confirm('Are you sure you want to delete this zone?')) {
|
if (window.confirm('Are you sure you want to delete this zone?')) {
|
||||||
try {
|
try {
|
||||||
@ -141,6 +176,10 @@ const Dashboard: React.FC = () => {
|
|||||||
.filter(zone => zone.status === 'ok')
|
.filter(zone => zone.status === 'ok')
|
||||||
.reduce((sum, zone) => sum + zone.area, 0);
|
.reduce((sum, zone) => sum + zone.area, 0);
|
||||||
|
|
||||||
|
const remainingArea = zones
|
||||||
|
.filter(zone => zone.status === 'due' || zone.status === 'overdue' || zone.status === 'new')
|
||||||
|
.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
|
||||||
@ -207,6 +246,29 @@ const Dashboard: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk Mowing Button */}
|
||||||
|
{view === 'dashboard' && zonesNeedingMowing.length > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{/*
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBulkMowingModal(true)}
|
||||||
|
className="bg-orange-600 hover:bg-orange-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 transition-colors duration-200 shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<Scissors className="h-5 w-5" />
|
||||||
|
Массовый покос ({zonesNeedingMowing.length})
|
||||||
|
</button>
|
||||||
|
*/}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBulkTrimmingModal(true)}
|
||||||
|
className="bg-purple-600 hover:bg-purple-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 transition-colors duration-200 shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<Zap className="h-5 w-5" />
|
||||||
|
Массовое выравнование
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowForm(true)}
|
onClick={() => setShowForm(true)}
|
||||||
className="bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 transition-colors duration-200 shadow-lg hover:shadow-xl"
|
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"
|
||||||
@ -279,7 +341,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">Внимание!</p>
|
<p className="text-sm font-medium text-gray-600">Внимание!</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">{dueCount + overdueCount}</p>
|
<p className="text-2xl font-bold text-gray-900">{dueCount + overdueCount + newCount}</p>
|
||||||
<p className="text-xs text-gray-500">зон для покоса</p>
|
<p className="text-xs text-gray-500">зон для покоса</p>
|
||||||
</div>
|
</div>
|
||||||
<Clock className="h-8 w-8 text-amber-500" />
|
<Clock className="h-8 w-8 text-amber-500" />
|
||||||
@ -412,6 +474,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<ZoneCard
|
<ZoneCard
|
||||||
zone={zone}
|
zone={zone}
|
||||||
onMarkAsMowed={handleMarkAsMowed}
|
onMarkAsMowed={handleMarkAsMowed}
|
||||||
|
onMarkAsTrimmed={handleMarkAsTrimmed}
|
||||||
onEdit={(zone) => {
|
onEdit={(zone) => {
|
||||||
setEditingZone(zone);
|
setEditingZone(zone);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
@ -441,6 +504,7 @@ const Dashboard: React.FC = () => {
|
|||||||
{showMowingModal && mowingZone && (
|
{showMowingModal && mowingZone && (
|
||||||
<MowingModal
|
<MowingModal
|
||||||
zoneName={mowingZone.name}
|
zoneName={mowingZone.name}
|
||||||
|
activityType="mowing"
|
||||||
onSubmit={handleMowingSubmit}
|
onSubmit={handleMowingSubmit}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setShowMowingModal(false);
|
setShowMowingModal(false);
|
||||||
@ -450,16 +514,40 @@ const Dashboard: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Single Zone Trimming Modal */}
|
||||||
|
{showTrimmingModal && trimmingZone && (
|
||||||
|
<MowingModal
|
||||||
|
zoneName={trimmingZone.name}
|
||||||
|
activityType="trimming"
|
||||||
|
onSubmit={handleTrimmingSubmit}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowTrimmingModal(false);
|
||||||
|
setTrimmingZone(null);
|
||||||
|
}}
|
||||||
|
loading={trimmingLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* Bulk Mowing Modal */}
|
{/* Bulk Mowing Modal */}
|
||||||
{showBulkMowingModal && (
|
{showBulkMowingModal && (
|
||||||
<BulkMowingModal
|
<BulkMowingModal
|
||||||
zones={zones}
|
zones={zones}
|
||||||
|
activityType="mowing"
|
||||||
onSubmit={handleBulkMowingSubmit}
|
onSubmit={handleBulkMowingSubmit}
|
||||||
onCancel={() => setShowBulkMowingModal(false)}
|
onCancel={() => setShowBulkMowingModal(false)}
|
||||||
loading={mowingLoading}
|
loading={mowingLoading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bulk Trimming Modal */}
|
||||||
|
{showBulkTrimmingModal && (
|
||||||
|
<BulkMowingModal
|
||||||
|
zones={zones}
|
||||||
|
activityType="trimming"
|
||||||
|
onSubmit={handleBulkTrimmingSubmit}
|
||||||
|
onCancel={() => setShowBulkTrimmingModal(false)}
|
||||||
|
loading={trimmingLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { History, BarChart, Calendar, Timer, Cloud, FileText, Square, ChevronDown, ChevronUp, Scissors, Users } from './Icons';
|
import { History, BarChart, Calendar, Timer, Cloud, FileText, Square, ChevronDown, ChevronUp, Scissors, Users, Zap } from './Icons';
|
||||||
import { MowingHistory, MowingHistoryResponse, MowingStats, Zone } from '../types/zone';
|
import { MowingHistory, MowingHistoryResponse, MowingStats, Zone } from '../types/zone';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
import Pagination from './Pagination';
|
import Pagination from './Pagination';
|
||||||
@ -114,6 +114,18 @@ const HistoryView: React.FC<HistoryViewProps> = ({ zones }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getActivityIcon = (activityType?: string) => {
|
||||||
|
return activityType === 'trimming' ? (
|
||||||
|
<Zap className="h-4 w-4 text-purple-600" />
|
||||||
|
) : (
|
||||||
|
<Scissors className="h-4 w-4 text-green-600" />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActivityLabel = (activityType?: string) => {
|
||||||
|
return activityType === 'trimming' ? 'Trimming' : 'Mowing';
|
||||||
|
};
|
||||||
|
|
||||||
// Group history entries by session
|
// Group history entries by session
|
||||||
const groupBySession = (entries: MowingHistory[]): (SessionGroup | MowingHistory)[] => {
|
const groupBySession = (entries: MowingHistory[]): (SessionGroup | MowingHistory)[] => {
|
||||||
const sessionMap = new Map<string, MowingHistory[]>();
|
const sessionMap = new Map<string, MowingHistory[]>();
|
||||||
@ -340,12 +352,26 @@ const HistoryView: React.FC<HistoryViewProps> = ({ zones }) => {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{session.entries[0]?.activityType === 'trimming' ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5 text-purple-600" />
|
||||||
|
<Zap className="h-4 w-4 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="h-5 w-5 text-blue-600" />
|
<Users className="h-5 w-5 text-blue-600" />
|
||||||
|
<Scissors className="h-4 w-4 text-green-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<h4 className="text-lg font-medium text-gray-900">
|
<h4 className="text-lg font-medium text-gray-900">
|
||||||
Сессия массового скашивания
|
Массовая {getActivityLabel(session.entries[0]?.activityType)} сессия
|
||||||
</h4>
|
</h4>
|
||||||
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
<span className={`text-xs font-medium px-2.5 py-0.5 rounded-full ${
|
||||||
|
session.entries[0]?.activityType === 'trimming'
|
||||||
|
? 'bg-purple-100 text-purple-800'
|
||||||
|
: 'bg-blue-100 text-blue-800'
|
||||||
|
}`}>
|
||||||
{session.entries.length} зоны
|
{session.entries.length} зоны
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -388,7 +414,7 @@ const HistoryView: React.FC<HistoryViewProps> = ({ zones }) => {
|
|||||||
{/* Zone Summary */}
|
{/* Zone Summary */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-gray-700">
|
||||||
<strong>Скошенные зоны:</strong> {session.entries.map(e => e.zoneName).join(', ')}
|
<strong>Зон {session.entries[0]?.activityType === 'trimming' ? 'подровнено' : 'скошено'}:</strong> {session.entries.map(e => e.zoneName).join(', ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -401,7 +427,7 @@ const HistoryView: React.FC<HistoryViewProps> = ({ zones }) => {
|
|||||||
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800 transition-colors duration-200"
|
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
<span>Session Notes</span>
|
<span>Заметки</span>
|
||||||
{expandedEntry === -index ? (
|
{expandedEntry === -index ? (
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
@ -442,7 +468,7 @@ const HistoryView: React.FC<HistoryViewProps> = ({ zones }) => {
|
|||||||
{session.entries.map(entry => (
|
{session.entries.map(entry => (
|
||||||
<div key={entry.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
<div key={entry.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Scissors className="h-4 w-4 text-green-600" />
|
{getActivityIcon(entry.activityType)}
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">{entry.zoneName}</p>
|
<p className="font-medium text-gray-900">{entry.zoneName}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
@ -472,9 +498,15 @@ const HistoryView: React.FC<HistoryViewProps> = ({ zones }) => {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
{getActivityIcon(entry.activityType)}
|
||||||
<h4 className="text-lg font-medium text-gray-900">
|
<h4 className="text-lg font-medium text-gray-900">
|
||||||
{entry.zoneName}
|
{entry.zoneName}
|
||||||
</h4>
|
</h4>
|
||||||
|
{entry.activityType === 'trimming' && (
|
||||||
|
<span className="bg-purple-100 text-purple-800 text-xs font-medium px-2 py-1 rounded-full">
|
||||||
|
Trimming
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
{entry.zoneArea > 0 && `${entry.zoneArea.toLocaleString()} м2`}
|
{entry.zoneArea > 0 && `${entry.zoneArea.toLocaleString()} м2`}
|
||||||
</span>
|
</span>
|
||||||
@ -518,7 +550,7 @@ const HistoryView: React.FC<HistoryViewProps> = ({ zones }) => {
|
|||||||
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800 transition-colors duration-200"
|
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
<span>Notes</span>
|
<span>Заметки</span>
|
||||||
{expandedEntry === entry.id ? (
|
{expandedEntry === entry.id ? (
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
|
@ -186,3 +186,8 @@ 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" }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<polygon points="13,2 3,14 12,14 11,22 21,10 12,10" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
|
||||||
|
</svg>
|
||||||
|
);
|
@ -1,10 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { X, Timer, Cloud, FileText, Scissors } from './Icons';
|
import { X, Timer, Cloud, FileText, Scissors, Zap } from './Icons';
|
||||||
import { MowingFormData, Mower } from '../types/zone';
|
import { MowingFormData, Mower } from '../types/zone';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
|
|
||||||
interface MowingModalProps {
|
interface MowingModalProps {
|
||||||
zoneName: string;
|
zoneName: string;
|
||||||
|
activityType?: 'mowing' | 'trimming';
|
||||||
onSubmit: (data: MowingFormData) => void;
|
onSubmit: (data: MowingFormData) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@ -12,6 +13,7 @@ interface MowingModalProps {
|
|||||||
|
|
||||||
const MowingModal: React.FC<MowingModalProps> = ({
|
const MowingModal: React.FC<MowingModalProps> = ({
|
||||||
zoneName,
|
zoneName,
|
||||||
|
activityType = 'mowing',
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
loading = false
|
loading = false
|
||||||
@ -21,6 +23,7 @@ const MowingModal: React.FC<MowingModalProps> = ({
|
|||||||
duration: undefined,
|
duration: undefined,
|
||||||
weather: '',
|
weather: '',
|
||||||
mowerId: undefined,
|
mowerId: undefined,
|
||||||
|
activityType,
|
||||||
});
|
});
|
||||||
const [mowers, setMowers] = useState<Mower[]>([]);
|
const [mowers, setMowers] = useState<Mower[]>([]);
|
||||||
const [loadingMowers, setLoadingMowers] = useState(true);
|
const [loadingMowers, setLoadingMowers] = useState(true);
|
||||||
@ -79,9 +82,21 @@ const MowingModal: React.FC<MowingModalProps> = ({
|
|||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
<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="bg-white rounded-lg shadow-xl max-w-md w-full">
|
||||||
<div className="flex items-center justify-between p-6 border-b">
|
<div className="flex items-center justify-between p-6 border-b">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">
|
<div>
|
||||||
Отметить как скошенное: {zoneName}
|
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
{activityType === 'trimming' ? (
|
||||||
|
<Zap className="h-6 w-6 text-purple-600" />
|
||||||
|
) : (
|
||||||
|
<Scissors className="h-6 w-6 text-green-600" />
|
||||||
|
)}
|
||||||
|
{activityType === 'trimming' ? 'Запись подравнивания' : 'Отметить как скошенное'}: {zoneName}
|
||||||
</h2>
|
</h2>
|
||||||
|
{activityType === 'trimming' && (
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Подравнивание не приводит к изменению графика скашивания
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="text-gray-400 hover:text-gray-600 transition-colors duration-200"
|
className="text-gray-400 hover:text-gray-600 transition-colors duration-200"
|
||||||
@ -94,8 +109,12 @@ const MowingModal: React.FC<MowingModalProps> = ({
|
|||||||
{/* Mower Selection */}
|
{/* Mower Selection */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="mowerId" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="mowerId" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{activityType === 'trimming' ? (
|
||||||
|
<Zap className="h-4 w-4 inline mr-2" />
|
||||||
|
) : (
|
||||||
<Scissors className="h-4 w-4 inline mr-2" />
|
<Scissors className="h-4 w-4 inline mr-2" />
|
||||||
Использована косилка
|
)}
|
||||||
|
{activityType === 'trimming' ? 'Использованный триммер' : 'Использованная газонокосилка'}
|
||||||
</label>
|
</label>
|
||||||
{loadingMowers ? (
|
{loadingMowers ? (
|
||||||
<div className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50">
|
<div className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50">
|
||||||
@ -110,9 +129,11 @@ const MowingModal: React.FC<MowingModalProps> = ({
|
|||||||
mowerId: e.target.value ? parseInt(e.target.value) : undefined
|
mowerId: e.target.value ? parseInt(e.target.value) : undefined
|
||||||
}))}
|
}))}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
required
|
required={activityType === 'mowing'}
|
||||||
>
|
>
|
||||||
<option value="">Выбор триммера</option>
|
<option value="">
|
||||||
|
{activityType === 'trimming' ? 'Вабор триммера (не обязательно)' : 'Выбор газонокосилки'}
|
||||||
|
</option>
|
||||||
{mowers.map(mower => (
|
{mowers.map(mower => (
|
||||||
<option key={mower.id} value={mower.id}>
|
<option key={mower.id} value={mower.id}>
|
||||||
{getMowerIcon(mower.type)} {mower.name} ({mower.type})
|
{getMowerIcon(mower.type)} {mower.name} ({mower.type})
|
||||||
@ -122,7 +143,7 @@ const MowingModal: React.FC<MowingModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
{mowers.length === 0 && !loadingMowers && (
|
{mowers.length === 0 && !loadingMowers && (
|
||||||
<p className="text-xs text-amber-600 mt-1">
|
<p className="text-xs text-amber-600 mt-1">
|
||||||
No mowers found. You can still record the mowing session without selecting a mower.
|
No mowers found. You can still record the {activityType} session without selecting equipment.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -146,7 +167,9 @@ const MowingModal: React.FC<MowingModalProps> = ({
|
|||||||
max="480"
|
max="480"
|
||||||
placeholder="например, 45"
|
placeholder="например, 45"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">Необязательно - сколько времени это заняло?</p>
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Необязательно - сколько времени заняла {activityType}?
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Weather */}
|
{/* Weather */}
|
||||||
@ -180,9 +203,15 @@ const MowingModal: React.FC<MowingModalProps> = ({
|
|||||||
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Какие-либо замечания, проблемы или специальные замечания..."
|
placeholder={
|
||||||
|
activityType === 'trimming'
|
||||||
|
? "Заметки о сеансе обрезки, обработанных участках, состоянии травы..."
|
||||||
|
: "Любые замечания, проблемы или специальные примечания..."
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">Необязательно - записывайте любые особые наблюдения.</p>
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Необязательно - запишите все особые замечания по поводу {activityType}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form Actions */}
|
{/* Form Actions */}
|
||||||
@ -197,9 +226,13 @@ const MowingModal: React.FC<MowingModalProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
className={`flex-1 py-2 px-4 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ${
|
||||||
|
activityType === 'trimming'
|
||||||
|
? 'bg-purple-600 hover:bg-purple-700'
|
||||||
|
: 'bg-green-600 hover:bg-green-700'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{loading ? 'Записывается...' : 'Отметить'}
|
{loading ? 'Recording...' : activityType === 'trimming' ? 'Подровнено' : 'Скошено'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Scissors, Edit, Trash2, Calendar, Camera, Square, Clock, AlertTriangle } from './Icons';
|
import { Scissors, Edit, Trash2, Calendar, Camera, Square, Clock, AlertTriangle, Zap } from './Icons';
|
||||||
import { Zone } from '../types/zone';
|
import { Zone } from '../types/zone';
|
||||||
|
|
||||||
interface ZoneCardProps {
|
interface ZoneCardProps {
|
||||||
zone: Zone;
|
zone: Zone;
|
||||||
onMarkAsMowed: (zone: Zone) => void;
|
onMarkAsMowed: (zone: Zone) => void;
|
||||||
|
onMarkAsTrimmed: (zone: Zone) => void;
|
||||||
onEdit: (zone: Zone) => void;
|
onEdit: (zone: Zone) => void;
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
}
|
}
|
||||||
@ -12,6 +13,7 @@ interface ZoneCardProps {
|
|||||||
const ZoneCard: React.FC<ZoneCardProps> = ({
|
const ZoneCard: React.FC<ZoneCardProps> = ({
|
||||||
zone,
|
zone,
|
||||||
onMarkAsMowed,
|
onMarkAsMowed,
|
||||||
|
onMarkAsTrimmed,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}) => {
|
}) => {
|
||||||
@ -139,6 +141,7 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onMarkAsMowed(zone)}
|
onClick={() => onMarkAsMowed(zone)}
|
||||||
@ -169,6 +172,16 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
|||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Trimming Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => onMarkAsTrimmed(zone)}
|
||||||
|
className="w-full py-2 px-3 bg-purple-600 hover:bg-purple-700 text-white rounded-md text-sm font-medium transition-colors duration-200 shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
<Zap className="h-4 w-4 inline mr-1" />
|
||||||
|
Подровнено
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -130,7 +130,7 @@ const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
|
|||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
placeholder="напр., 150.5"
|
placeholder="например, 150.5"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">Дополнительно - помогает отслеживать общую площадь газона</p>
|
<p className="text-xs text-gray-500 mt-1">Дополнительно - помогает отслеживать общую площадь газона</p>
|
||||||
</div>
|
</div>
|
||||||
@ -188,7 +188,7 @@ const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
|
|||||||
required
|
required
|
||||||
min="1"
|
min="1"
|
||||||
max="365"
|
max="365"
|
||||||
placeholder="напр., 7"
|
placeholder="например, 7"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">Как часто следует косить эту зону?</p>
|
<p className="text-xs text-gray-500 mt-1">Как часто следует косить эту зону?</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -86,6 +86,16 @@ export const api = {
|
|||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async markAsTrimmed(id: number, data?: MowingFormData): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE}/zones/${id}/trim`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data || {}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to mark as trimmed');
|
||||||
|
},
|
||||||
async bulkMarkAsMowed(data: BulkMowingFormData): Promise<void> {
|
async bulkMarkAsMowed(data: BulkMowingFormData): Promise<void> {
|
||||||
const response = await fetch(`${API_BASE}/zones/bulk-mow`, {
|
const response = await fetch(`${API_BASE}/zones/bulk-mow`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
@ -53,9 +53,10 @@ export interface MowingHistory {
|
|||||||
mowerType?: string;
|
mowerType?: string;
|
||||||
mowedDate: string;
|
mowedDate: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
sessionId?: string;
|
|
||||||
duration?: number; // in minutes
|
duration?: number; // in minutes
|
||||||
weather?: string;
|
weather?: string;
|
||||||
|
activityType?: 'mowing' | 'trimming';
|
||||||
|
sessionId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,6 +100,7 @@ export interface MowingFormData {
|
|||||||
duration?: number;
|
duration?: number;
|
||||||
weather?: string;
|
weather?: string;
|
||||||
mowerId?: number;
|
mowerId?: number;
|
||||||
|
activityType?: 'mowing' | 'trimming';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BulkMowingFormData {
|
export interface BulkMowingFormData {
|
||||||
@ -107,4 +109,5 @@ export interface BulkMowingFormData {
|
|||||||
totalDuration?: number;
|
totalDuration?: number;
|
||||||
weather?: string;
|
weather?: string;
|
||||||
mowerId?: number;
|
mowerId?: number;
|
||||||
|
activityType?: 'mowing' | 'trimming';
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user