Версия 0.2.0. Добавлены сессии подравнивания. Исправлены файлы развертывания в Docker.
This commit is contained in:
parent
948b2a9f62
commit
b18e0d847e
@ -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"]
|
@ -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.
@ -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\"",
|
||||
|
@ -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,6 +839,8 @@ app.post('/api/zones/bulk-mow', async (req, res) => {
|
||||
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
|
||||
let sql, args;
|
||||
|
||||
@ -815,17 +855,18 @@ app.post('/api/zones/bulk-mow', async (req, res) => {
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
|
@ -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">
|
||||
{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">
|
||||
{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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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[]>();
|
||||
@ -340,12 +352,26 @@ 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">
|
||||
<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">
|
||||
<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" />
|
||||
) : (
|
||||
|
@ -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>
|
||||
);
|
@ -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}
|
||||
<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">
|
||||
{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>
|
||||
|
@ -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,6 +141,7 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onMarkAsMowed(zone)}
|
||||
@ -169,6 +172,16 @@ const ZoneCard: React.FC<ZoneCardProps> = ({
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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';
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user