Версия 0.2.0. Добавлены сессии подравнивания. Исправлены файлы развертывания в Docker.

This commit is contained in:
anibilag 2025-07-08 22:24:18 +03:00
parent 948b2a9f62
commit b18e0d847e
14 changed files with 356 additions and 109 deletions

View File

@ -1,26 +1,21 @@
# Development Dockerfile
FROM node:18-alpine
# Используем официальный образ Node.js
FROM node:20
# Создаем рабочую директорию
WORKDIR /app
# Install dependencies
# Копируем package.json и устанавливаем зависимости
COPY package*.json ./
RUN npm install
# Copy source code
# Копируем остальные файлы проекта
COPY . .
# Create uploads directory
RUN mkdir -p uploads
# Меняем владельца рабочей директории на пользователя node
RUN chown -R node:node /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Change ownership of app directory
RUN chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 3001 5173
# Переключаемся на безопасного пользователя node
USER node
# Запускаем dev-сервер
CMD ["npm", "run", "dev"]

View File

@ -8,8 +8,8 @@ services:
container_name: lawn-scheduler-dev
restart: unless-stopped
ports:
- "3001:3001"
- "5173:5173"
- "3001:3001" # Backend API
- "5173:5173" # Vite dev server
volumes:
- .:/app
- /app/node_modules
@ -21,11 +21,11 @@ services:
- lawn-scheduler-dev-network
nginx:
image: webdevops/nginx:alpine
image: nginx:alpine
container_name: lawn-scheduler-nginx-dev
restart: unless-stopped
ports:
- "8080:80"
- "8080:80" # Внешний доступ к фронту через NGINX
volumes:
- ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro
- ./uploads:/var/www/uploads:ro

Binary file not shown.

View File

@ -1,7 +1,7 @@
{
"name": "lawn-mowing-scheduler",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "concurrently \"npm run server\" \"npm run client\"",

View File

@ -98,6 +98,7 @@ await db.execute(`
notes TEXT,
duration INTEGER, -- in minutes
weather TEXT,
activityType TEXT DEFAULT 'mowing', -- 'mowing' or 'trimming'
sessionId TEXT, -- for grouping bulk mowing sessions
createdAt TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (zoneId) REFERENCES zones (id) ON DELETE CASCADE,
@ -139,6 +140,13 @@ try {
// 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
const mowerCountResult = await db.execute('SELECT COUNT(*) as count FROM mowers');
const mowerCount = mowerCountResult.rows[0].count;
@ -433,6 +441,7 @@ app.get('/api/history', async (req, res) => {
notes: row.notes,
duration: row.duration,
weather: row.weather,
activityType: row.activityType || 'mowing',
sessionId: row.sessionId,
createdAt: row.createdAt
}));
@ -733,8 +742,8 @@ app.post('/api/zones/:id/mow', async (req, res) => {
// Add to mowing history
await db.execute({
sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather) VALUES (?, ?, ?, ?, ?, ?)',
args: [req.params.id, mowerId || null, today, notes || null, duration || null, weather || null]
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, 'mowing']
});
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
app.post('/api/zones/bulk-mow', async (req, res) => {
try {
const { selectedZoneIds, notes, totalDuration, weather, mowerId } = req.body;
const { selectedZoneIds, notes, totalDuration, weather, mowerId, activityType = 'mowing' } = req.body;
if (!selectedZoneIds || selectedZoneIds.length === 0) {
return res.status(400).json({ error: 'No zones selected' });
@ -801,31 +839,34 @@ app.post('/api/zones/bulk-mow', async (req, res) => {
zoneDuration = Math.round(totalDuration * proportion);
}
// Update zone's last mowed date and calculate next mow date if using interval scheduling
let sql, args;
// 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
let sql, args;
if (zone.scheduleType === 'interval' && zone.intervalDays) {
const nextMowDate = calculateNextMowDate(today, zone.intervalDays);
sql = 'UPDATE zones SET lastMowedDate = ?, nextMowDate = ? WHERE id = ?';
args = [today, nextMowDate, zone.id];
} else {
// For specific date scheduling, just update last mowed date
sql = 'UPDATE zones SET lastMowedDate = ? WHERE id = ?';
args = [today, zone.id];
if (zone.scheduleType === 'interval' && zone.intervalDays) {
const nextMowDate = calculateNextMowDate(today, zone.intervalDays);
sql = 'UPDATE zones SET lastMowedDate = ?, nextMowDate = ? WHERE id = ?';
args = [today, nextMowDate, zone.id];
} else {
// For specific date scheduling, just update last mowed date
sql = 'UPDATE zones SET lastMowedDate = ? WHERE id = ?';
args = [today, zone.id];
}
await db.execute({ sql, args });
}
await db.execute({ sql, args });
// Add to mowing history with session ID
await db.execute({
sql: 'INSERT INTO mowing_history (zoneId, mowerId, mowedDate, notes, duration, weather, sessionId) VALUES (?, ?, ?, ?, ?, ?, ?)',
args: [zone.id, mowerId || null, today, notes || null, zoneDuration, weather || null, sessionId]
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, activityType]
});
}
res.json({
success: true,
message: `Successfully recorded mowing session for ${zones.length} zones`,
message: `Successfully recorded ${activityType} session for ${zones.length} zones`,
sessionId,
zonesUpdated: zones.length
});

View File

@ -1,10 +1,11 @@
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 { api } from '../services/api';
interface BulkMowingModalProps {
zones: Zone[];
activityType?: 'mowing' | 'trimming';
onSubmit: (data: BulkMowingFormData) => void;
onCancel: () => void;
loading?: boolean;
@ -12,6 +13,7 @@ interface BulkMowingModalProps {
const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
zones,
activityType = 'mowing',
onSubmit,
onCancel,
loading = false
@ -22,6 +24,7 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
totalDuration: undefined,
weather: '',
mowerId: undefined,
activityType,
});
const [mowers, setMowers] = useState<Mower[]>([]);
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>
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<Scissors className="h-6 w-6 text-green-600" />
Сеанс массового скашивания
{activityType === 'trimming' ? (
<Zap className="h-6 w-6 text-purple-600" />
) : (
<Scissors className="h-6 w-6 text-green-600" />
)}
{activityType === 'trimming' ? 'Сеанс массового подравнивания' : 'Сеанс массового покоса'}
</h2>
<p className="text-sm text-gray-600 mt-1">
Выберите несколько зон, скошенных за один сеанс
{activityType === 'trimming'
? 'Выберите несколько зон, обрезанных за один сеанс (не сбрасывает расписание скашивания).'
: 'Выберите несколько зон, скошенных за один сеанс'
}
</p>
</div>
<button
@ -207,8 +217,12 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
{/* Mower Selection */}
<div>
<label htmlFor="mowerId" className="block text-sm font-medium text-gray-700 mb-2">
<Scissors className="h-4 w-4 inline mr-2" />
Использованная газонокосилка
{activityType === 'trimming' ? (
<Zap className="h-4 w-4 inline mr-2" />
) : (
<Scissors className="h-4 w-4 inline mr-2" />
)}
{activityType === 'trimming' ? 'Использованный триммер' : 'Использованная газонокосилка'}
</label>
{loadingMowers ? (
<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
}))}
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 => (
<option key={mower.id} value={mower.id}>
{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"
min="1"
max="480"
placeholder="напр., 90"
placeholder="например, 90"
/>
<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 }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
rows={3}
placeholder="Заметки о сеансе стрижки газона..."
placeholder={
activityType === 'trimming'
? "Заметки о сеансе подравнивания..."
: "Заметки о сеансе кошения..."
}
/>
</div>
</div>
@ -300,7 +320,7 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-900 mb-3 flex items-center gap-2">
<Timer className="h-4 w-4" />
Предварительный просмотр распределения времени
{activityType === 'trimming' ? 'Trimming' : 'Mowing'} Time Distribution Preview
</h4>
<div className="space-y-2">
{timeDistribution.map(({ zone, proportion, allocatedTime }) => (
@ -344,9 +364,16 @@ const BulkMowingModal: React.FC<BulkMowingModalProps> = ({
<button
type="submit"
disabled={loading || formData.selectedZoneIds.length === 0}
className="flex-1 py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
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>
</div>
</form>

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Plus, Filter, Calendar, Scissors, AlertTriangle, Square, CheckCircle, Clock, Map, History } from './Icons';
import { Plus, Filter, Calendar, Scissors, AlertTriangle, Square, CheckCircle, Clock, Map, History, Zap } from './Icons';
import { Zone, MowingFormData, BulkMowingFormData } from '../types/zone';
import { api } from '../services/api';
import ZoneCard from './ZoneCard';
@ -7,7 +7,7 @@ import ZoneForm from './ZoneForm';
import SitePlan from './SitePlan';
import HistoryView from './HistoryView';
import MowingModal from './MowingModal';
import BulkMowingModal from "./BulkMowingModal";
import BulkMowingModal from './BulkMowingModal';
type FilterType = 'all' | 'due' | 'overdue' | 'new';
type ViewType = 'dashboard' | 'sitePlan' | 'history';
@ -22,9 +22,13 @@ const Dashboard: React.FC = () => {
const [selectedZoneId, setSelectedZoneId] = useState<number | undefined>();
const [showMowingModal, setShowMowingModal] = 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 [trimmingZone, setTrimmingZone] = useState<Zone | null>(null);
const [loading, setLoading] = useState(true);
const [mowingLoading, setMowingLoading] = useState(false);
const [trimmingLoading, setTrimmingLoading] = useState(false);
useEffect(() => {
loadZones();
@ -68,6 +72,10 @@ const Dashboard: React.FC = () => {
setShowMowingModal(true);
};
const handleMarkAsTrimmed = (zone: Zone) => {
setTrimmingZone(zone);
setShowTrimmingModal(true);
};
const handleMowingSubmit = async (data: MowingFormData) => {
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) => {
setMowingLoading(true);
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) => {
if (window.confirm('Are you sure you want to delete this zone?')) {
try {
@ -141,6 +176,10 @@ const Dashboard: React.FC = () => {
.filter(zone => zone.status === 'ok')
.reduce((sum, zone) => sum + zone.area, 0);
const remainingArea = zones
.filter(zone => zone.status === 'due' || zone.status === 'overdue' || zone.status === 'new')
.reduce((sum, zone) => sum + zone.area, 0);
const mowedPercentage = totalArea > 0 ? (mowedArea / totalArea) * 100 : 0;
// Check if there are zones that need mowing for bulk action
@ -207,6 +246,29 @@ const Dashboard: React.FC = () => {
</button>
</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
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"
@ -279,7 +341,7 @@ const Dashboard: React.FC = () => {
<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}</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" />
@ -412,6 +474,7 @@ const Dashboard: React.FC = () => {
<ZoneCard
zone={zone}
onMarkAsMowed={handleMarkAsMowed}
onMarkAsTrimmed={handleMarkAsTrimmed}
onEdit={(zone) => {
setEditingZone(zone);
setShowForm(true);
@ -441,6 +504,7 @@ const Dashboard: React.FC = () => {
{showMowingModal && mowingZone && (
<MowingModal
zoneName={mowingZone.name}
activityType="mowing"
onSubmit={handleMowingSubmit}
onCancel={() => {
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 */}
{showBulkMowingModal && (
<BulkMowingModal
zones={zones}
activityType="mowing"
onSubmit={handleBulkMowingSubmit}
onCancel={() => setShowBulkMowingModal(false)}
loading={mowingLoading}
/>
)}
{/* Bulk Trimming Modal */}
{showBulkTrimmingModal && (
<BulkMowingModal
zones={zones}
activityType="trimming"
onSubmit={handleBulkTrimmingSubmit}
onCancel={() => setShowBulkTrimmingModal(false)}
loading={trimmingLoading}
/>
)}
</div>
</div>
);

View File

@ -1,5 +1,5 @@
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 { api } from '../services/api';
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
const groupBySession = (entries: MowingHistory[]): (SessionGroup | MowingHistory)[] => {
const sessionMap = new Map<string, MowingHistory[]>();
@ -341,11 +353,25 @@ const HistoryView: React.FC<HistoryViewProps> = ({ zones }) => {
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-blue-600" />
{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">
<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">
Сессия массового скашивания
Массовая {getActivityLabel(session.entries[0]?.activityType)} сессия
</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} зоны
</span>
</div>
@ -388,7 +414,7 @@ const HistoryView: React.FC<HistoryViewProps> = ({ zones }) => {
{/* Zone Summary */}
<div className="mb-3">
<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>
</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"
>
<FileText className="h-4 w-4" />
<span>Session Notes</span>
<span>Заметки</span>
{expandedEntry === -index ? (
<ChevronUp className="h-4 w-4" />
) : (
@ -442,7 +468,7 @@ const HistoryView: React.FC<HistoryViewProps> = ({ zones }) => {
{session.entries.map(entry => (
<div key={entry.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div className="flex items-center gap-3">
<Scissors className="h-4 w-4 text-green-600" />
{getActivityIcon(entry.activityType)}
<div>
<p className="font-medium text-gray-900">{entry.zoneName}</p>
<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-1">
<div className="flex items-center gap-3 mb-2">
{getActivityIcon(entry.activityType)}
<h4 className="text-lg font-medium text-gray-900">
{entry.zoneName}
</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">
{entry.zoneArea > 0 && `${entry.zoneArea.toLocaleString()} м2`}
</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"
>
<FileText className="h-4 w-4" />
<span>Notes</span>
<span>Заметки</span>
{expandedEntry === entry.id ? (
<ChevronUp className="h-4 w-4" />
) : (

View File

@ -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" />
</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>
);

View File

@ -1,10 +1,11 @@
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 { api } from '../services/api';
interface MowingModalProps {
zoneName: string;
activityType?: 'mowing' | 'trimming';
onSubmit: (data: MowingFormData) => void;
onCancel: () => void;
loading?: boolean;
@ -12,6 +13,7 @@ interface MowingModalProps {
const MowingModal: React.FC<MowingModalProps> = ({
zoneName,
activityType = 'mowing',
onSubmit,
onCancel,
loading = false
@ -21,6 +23,7 @@ const MowingModal: React.FC<MowingModalProps> = ({
duration: undefined,
weather: '',
mowerId: undefined,
activityType,
});
const [mowers, setMowers] = useState<Mower[]>([]);
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="bg-white rounded-lg shadow-xl max-w-md w-full">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900">
Отметить как скошенное: {zoneName}
</h2>
<div>
<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>
{activityType === 'trimming' && (
<p className="text-sm text-gray-600 mt-1">
Подравнивание не приводит к изменению графика скашивания
</p>
)}
</div>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600 transition-colors duration-200"
@ -94,8 +109,12 @@ const MowingModal: React.FC<MowingModalProps> = ({
{/* Mower Selection */}
<div>
<label htmlFor="mowerId" className="block text-sm font-medium text-gray-700 mb-2">
<Scissors className="h-4 w-4 inline mr-2" />
Использована косилка
{activityType === 'trimming' ? (
<Zap className="h-4 w-4 inline mr-2" />
) : (
<Scissors className="h-4 w-4 inline mr-2" />
)}
{activityType === 'trimming' ? 'Использованный триммер' : 'Использованная газонокосилка'}
</label>
{loadingMowers ? (
<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
}))}
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 => (
<option key={mower.id} value={mower.id}>
{getMowerIcon(mower.type)} {mower.name} ({mower.type})
@ -122,7 +143,7 @@ const MowingModal: React.FC<MowingModalProps> = ({
)}
{mowers.length === 0 && !loadingMowers && (
<p className="text-xs text-amber-600 mt-1">
No mowers found. You can still record the mowing session without selecting a mower.
No mowers found. You can still record the {activityType} session without selecting equipment.
</p>
)}
</div>
@ -146,7 +167,9 @@ const MowingModal: React.FC<MowingModalProps> = ({
max="480"
placeholder="например, 45"
/>
<p className="text-xs text-gray-500 mt-1">Необязательно - сколько времени это заняло?</p>
<p className="text-xs text-gray-500 mt-1">
Необязательно - сколько времени заняла {activityType}?
</p>
</div>
{/* Weather */}
@ -180,9 +203,15 @@ const MowingModal: React.FC<MowingModalProps> = ({
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
rows={3}
placeholder="Какие-либо замечания, проблемы или специальные замечания..."
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>
{/* Form Actions */}
@ -197,9 +226,13 @@ const MowingModal: React.FC<MowingModalProps> = ({
<button
type="submit"
disabled={loading}
className="flex-1 py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
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>
</div>
</form>

View File

@ -1,10 +1,11 @@
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';
interface ZoneCardProps {
zone: Zone;
onMarkAsMowed: (zone: Zone) => void;
onMarkAsTrimmed: (zone: Zone) => void;
onEdit: (zone: Zone) => void;
onDelete: (id: number) => void;
}
@ -12,6 +13,7 @@ interface ZoneCardProps {
const ZoneCard: React.FC<ZoneCardProps> = ({
zone,
onMarkAsMowed,
onMarkAsTrimmed,
onEdit,
onDelete,
}) => {
@ -139,34 +141,45 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<button
onClick={() => onMarkAsMowed(zone)}
disabled={zone.status === 'ok' && zone.daysUntilNext! > 1}
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors duration-200 ${
zone.status === 'ok' && zone.daysUntilNext! > 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: zone.isNew
? 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm hover:shadow-md'
: 'bg-green-600 hover:bg-green-700 text-white shadow-sm hover:shadow-md'
}`}
>
<Scissors className="h-4 w-4 inline mr-1" />
{zone.isNew ? 'Первый покос' : 'Скошенно'}
</button>
<div className="space-y-2">
<div className="flex gap-2">
<button
onClick={() => onMarkAsMowed(zone)}
disabled={zone.status === 'ok' && zone.daysUntilNext! > 1}
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors duration-200 ${
zone.status === 'ok' && zone.daysUntilNext! > 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: zone.isNew
? 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm hover:shadow-md'
: 'bg-green-600 hover:bg-green-700 text-white shadow-sm hover:shadow-md'
}`}
>
<Scissors className="h-4 w-4 inline mr-1" />
{zone.isNew ? 'Первый покос' : 'Скошенно'}
</button>
<button
onClick={() => onEdit(zone)}
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors duration-200"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => onEdit(zone)}
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors duration-200"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => onDelete(zone.id)}
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors duration-200"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
{/* Trimming Button */}
<button
onClick={() => onDelete(zone.id)}
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors duration-200"
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"
>
<Trash2 className="h-4 w-4" />
<Zap className="h-4 w-4 inline mr-1" />
Подровнено
</button>
</div>
</div>

View File

@ -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"
min="0"
step="0.1"
placeholder="напр., 150.5"
placeholder="например, 150.5"
/>
<p className="text-xs text-gray-500 mt-1">Дополнительно - помогает отслеживать общую площадь газона</p>
</div>
@ -188,7 +188,7 @@ const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
required
min="1"
max="365"
placeholder="напр., 7"
placeholder="например, 7"
/>
<p className="text-xs text-gray-500 mt-1">Как часто следует косить эту зону?</p>
</div>

View File

@ -86,6 +86,16 @@ export const api = {
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> {
const response = await fetch(`${API_BASE}/zones/bulk-mow`, {
method: 'POST',

View File

@ -53,9 +53,10 @@ export interface MowingHistory {
mowerType?: string;
mowedDate: string;
notes?: string;
sessionId?: string;
duration?: number; // in minutes
weather?: string;
activityType?: 'mowing' | 'trimming';
sessionId?: string;
createdAt: string;
}
@ -99,6 +100,7 @@ export interface MowingFormData {
duration?: number;
weather?: string;
mowerId?: number;
activityType?: 'mowing' | 'trimming';
}
export interface BulkMowingFormData {
@ -107,4 +109,5 @@ export interface BulkMowingFormData {
totalDuration?: number;
weather?: string;
mowerId?: number;
activityType?: 'mowing' | 'trimming';
}