From b18e0d847e46abee9f68b7fa60cdd35546033384 Mon Sep 17 00:00:00 2001 From: anibilag Date: Tue, 8 Jul 2025 22:24:18 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D1=80=D1=81=D0=B8=D1=8F=200.2.0.?= =?UTF-8?q?=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8=20=D0=BF=D0=BE=D0=B4=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BD=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F.=20?= =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D1=80=D0=B0=D0=B7=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=82=D1=8B=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B2=20Doc?= =?UTF-8?q?ker.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile.dev | 27 ++++----- docker-compose.dev.yml | 10 +-- lawn_scheduler.db | Bin 20480 -> 20480 bytes package.json | 2 +- server/index.js | 79 ++++++++++++++++++------ src/components/BulkMowingModal.tsx | 53 ++++++++++++---- src/components/Dashboard.tsx | 94 ++++++++++++++++++++++++++++- src/components/HistoryView.tsx | 48 ++++++++++++--- src/components/Icons.tsx | 5 ++ src/components/MowingModal.tsx | 61 ++++++++++++++----- src/components/ZoneCard.tsx | 67 +++++++++++--------- src/components/ZoneForm.tsx | 4 +- src/services/api.ts | 10 +++ src/types/zone.ts | 5 +- 14 files changed, 356 insertions(+), 109 deletions(-) diff --git a/Dockerfile.dev b/Dockerfile.dev index 90e7870..9259f49 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -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 +# Переключаемся на безопасного пользователя node +USER node -# Change ownership of app directory -RUN chown -R nodejs:nodejs /app -USER nodejs - -EXPOSE 3001 5173 - -CMD ["npm", "run", "dev"] \ No newline at end of file +# Запускаем dev-сервер +CMD ["npm", "run", "dev"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index de8133b..f3ac887 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 @@ -36,4 +36,4 @@ services: networks: lawn-scheduler-dev-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/lawn_scheduler.db b/lawn_scheduler.db index f668e8ef8b32614d58a9b60de32d94c3c8805369..a7d1b9050a74f88c339e04f05a8eea26ed969de3 100644 GIT binary patch delta 780 zcmZozz}T>Wae}m<2Ll5GD-go~$3z`tQ4a>aE+JlCQwA2U6b9Y^zS&$UJeC_9KX7t2 z2QjmYYicsK#c!U(mBgs5keFPOS(aH+8B$r0su1ED5u)JY>gE{g6QZD=n_r%pm#)6~ zGdCZLJkJRR9&T|)z9ioN{6hR~yz{v4bJy{j@SNus=jr0{*{mq=ohv<+pNm10!IqKH z*jDz!tPA@t>;~e!7xrH4HZm|W)ip2&q7WkkDn+5{ zJ^7`+v;-G-J0q_HzaP(azNdWEd_ue{dD6MRbGL66RLJI5&*SE1V6Wae}mG zn)R63#Wgh<+r&5LaV0Ts{=&`6BF~%4z{6d~z?a1PpI?Z-jdvdReeOD56Q2J(CwRJe zd^QUz#Bpz~llEftH|6JI&}3j>WHe@wy)f&-z6-m7c<+V17rXhCjSP%Tbq&mQ4NMda z4XjLo$TTyrB(o3Gnw3IhO3S!|&I diff --git a/package.json b/package.json index 461ac2c..3b93433 100644 --- a/package.json +++ b/package.json @@ -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\"", diff --git a/server/index.js b/server/index.js index 959425b..c10bcf4 100644 --- a/server/index.js +++ b/server/index.js @@ -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; - - 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]; + // 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]; + } + + 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 }); diff --git a/src/components/BulkMowingModal.tsx b/src/components/BulkMowingModal.tsx index a490a43..bfba797 100644 --- a/src/components/BulkMowingModal.tsx +++ b/src/components/BulkMowingModal.tsx @@ -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 = ({ zones, + activityType = 'mowing', onSubmit, onCancel, loading = false @@ -22,6 +24,7 @@ const BulkMowingModal: React.FC = ({ totalDuration: undefined, weather: '', mowerId: undefined, + activityType, }); const [mowers, setMowers] = useState([]); const [loadingMowers, setLoadingMowers] = useState(true); @@ -140,11 +143,18 @@ const BulkMowingModal: React.FC = ({

- - Сеанс массового скашивания + {activityType === 'trimming' ? ( + + ) : ( + + )} + {activityType === 'trimming' ? 'Сеанс массового подравнивания' : 'Сеанс массового покоса'}

- Выберите несколько зон, скошенных за один сеанс + {activityType === 'trimming' + ? 'Выберите несколько зон, обрезанных за один сеанс (не сбрасывает расписание скашивания).' + : 'Выберите несколько зон, скошенных за один сеанс' + }

diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 906cade..54a5a20 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -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(); const [showMowingModal, setShowMowingModal] = useState(false); const [showBulkMowingModal, setShowBulkMowingModal] = useState(false); + const [showTrimmingModal, setShowTrimmingModal] = useState(false); + const [showBulkTrimmingModal, setShowBulkTrimmingModal] = useState(false); const [mowingZone, setMowingZone] = useState(null); + const [trimmingZone, setTrimmingZone] = useState(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 { @@ -140,6 +175,10 @@ const Dashboard: React.FC = () => { const mowedArea = zones .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; @@ -206,7 +245,30 @@ const Dashboard: React.FC = () => { История + + {/* Bulk Mowing Button */} + {view === 'dashboard' && zonesNeedingMowing.length > 0 && ( +
+{/* + +*/} + +
+ )} + diff --git a/src/components/ZoneCard.tsx b/src/components/ZoneCard.tsx index b4f0144..53bbccc 100644 --- a/src/components/ZoneCard.tsx +++ b/src/components/ZoneCard.tsx @@ -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 = ({ zone, onMarkAsMowed, + onMarkAsTrimmed, onEdit, onDelete, }) => { @@ -139,34 +141,45 @@ const ZoneCard: React.FC = ({ {/* Action Buttons */} -
+
+
+ + + + + +
+ + {/* Trimming Button */} - - - -
diff --git a/src/components/ZoneForm.tsx b/src/components/ZoneForm.tsx index f4f1a40..36a008a 100644 --- a/src/components/ZoneForm.tsx +++ b/src/components/ZoneForm.tsx @@ -130,7 +130,7 @@ const ZoneForm: React.FC = ({ 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" />

Дополнительно - помогает отслеживать общую площадь газона

@@ -188,7 +188,7 @@ const ZoneForm: React.FC = ({ zone, onSubmit, onCancel }) => { required min="1" max="365" - placeholder="напр., 7" + placeholder="например, 7" />

Как часто следует косить эту зону?

diff --git a/src/services/api.ts b/src/services/api.ts index 3b56ab0..1808500 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -86,6 +86,16 @@ export const api = { return response.json(); }, + async markAsTrimmed(id: number, data?: MowingFormData): Promise { + 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 { const response = await fetch(`${API_BASE}/zones/bulk-mow`, { method: 'POST', diff --git a/src/types/zone.ts b/src/types/zone.ts index 4cc33a1..84c0bb3 100644 --- a/src/types/zone.ts +++ b/src/types/zone.ts @@ -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'; } \ No newline at end of file