first commit

This commit is contained in:
anibilag 2025-06-29 16:26:42 +03:00
commit 78d9777689
33 changed files with 8133 additions and 0 deletions

3
.bolt/config.json Normal file
View File

@ -0,0 +1,3 @@
{
"template": "bolt-vite-react-ts"
}

5
.bolt/prompt Normal file
View File

@ -0,0 +1,5 @@
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
Use icons from lucide-react for logos.

19
.dockerignore Normal file
View File

@ -0,0 +1,19 @@
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.nyc_output
coverage
.nyc_output
.vscode
.DS_Store
*.log
dist
build
.next
.nuxt
.cache
.parcel-cache
.eslintcache

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env

49
Dockerfile Normal file
View File

@ -0,0 +1,49 @@
# Multi-stage build for production
FROM node:18-alpine AS frontend-build
WORKDIR /app/frontend
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Backend build stage
FROM node:18-alpine AS backend-build
WORKDIR /app/backend
COPY package*.json ./
RUN npm ci --only=production
COPY server/ ./server/
COPY package.json ./
# Production stage
FROM node:18-alpine AS production
WORKDIR /app
# Install backend dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy backend files
COPY server/ ./server/
# Copy built frontend files
COPY --from=frontend-build /app/dist ./public
# Create uploads directory
RUN mkdir -p uploads
# 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
CMD ["node", "server/index.js"]

26
Dockerfile.dev Normal file
View File

@ -0,0 +1,26 @@
# Development Dockerfile
FROM node:18-alpine
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm install
# Copy source code
COPY . .
# Create uploads directory
RUN mkdir -p uploads
# 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
CMD ["npm", "run", "dev"]

39
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,39 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
container_name: lawn-scheduler-dev
restart: unless-stopped
ports:
- "3001:3001"
- "5173:5173"
volumes:
- .:/app
- /app/node_modules
- ./uploads:/app/uploads
environment:
- NODE_ENV=development
- CHOKIDAR_USEPOLLING=true
networks:
- lawn-scheduler-dev-network
nginx:
image: webdevops/nginx:alpine
container_name: lawn-scheduler-nginx-dev
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro
- ./uploads:/var/www/uploads:ro
networks:
- lawn-scheduler-dev-network
depends_on:
- app
networks:
lawn-scheduler-dev-network:
driver: bridge

42
docker-compose.yml Normal file
View File

@ -0,0 +1,42 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: lawn-scheduler-app
restart: unless-stopped
volumes:
- ./uploads:/app/uploads
- ./lawn_scheduler.db:/app/lawn_scheduler.db
environment:
- NODE_ENV=production
networks:
- lawn-scheduler-network
depends_on:
- nginx
nginx:
image: webdevops/nginx:alpine
container_name: lawn-scheduler-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./uploads:/var/www/uploads:ro
networks:
- lawn-scheduler-network
depends_on:
- app
networks:
lawn-scheduler-network:
driver: bridge
volumes:
uploads:
database:

28
eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lawn Mowing Schedule Manager - Full Stack Application</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

109
nginx/nginx.conf Normal file
View File

@ -0,0 +1,109 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 10M;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
upstream backend {
server app:3001;
keepalive 32;
}
server {
listen 80;
server_name _;
root /var/www/html;
index index.html;
# API routes
location /api/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
}
# Uploads
location /uploads/ {
alias /var/www/uploads/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Static files
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Frontend routes (SPA)
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# Health check
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
}

75
nginx/nginx.dev.conf Normal file
View File

@ -0,0 +1,75 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
client_max_body_size 10M;
upstream frontend {
server app:5173;
}
upstream backend {
server app:3001;
}
server {
listen 80;
server_name _;
# API routes
location /api/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Uploads
location /uploads/ {
alias /var/www/uploads/;
}
# Vite HMR WebSocket
location /ws {
proxy_pass http://frontend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
# Frontend (Vite dev server)
location / {
proxy_pass http://frontend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
}

6064
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "lawn-mowing-scheduler",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "concurrently \"npm run server\" \"npm run client\"",
"client": "vite",
"server": "nodemon server/index.js",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"@libsql/client": "^0.4.0",
"express": "^4.18.2",
"cors": "^2.8.5",
"multer": "^1.4.5-lts.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2",
"concurrently": "^8.2.2",
"nodemon": "^3.0.2"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

18
scripts/start-dev.sh Normal file
View File

@ -0,0 +1,18 @@
#!/bin/bash
echo "Starting Lawn Scheduler in Development Mode..."
# Create necessary directories
mkdir -p uploads
# Build and start services
docker-compose -f docker-compose.dev.yml down
docker-compose -f docker-compose.dev.yml build --no-cache
docker-compose -f docker-compose.dev.yml up -d
echo "Development environment started!"
echo "Frontend (Vite): http://localhost:8080"
echo "Backend API: http://localhost:3001"
echo ""
echo "To view logs: docker-compose -f docker-compose.dev.yml logs -f"
echo "To stop: docker-compose -f docker-compose.dev.yml down"

18
scripts/start-prod.sh Normal file
View File

@ -0,0 +1,18 @@
#!/bin/bash
echo "Starting Lawn Scheduler in Production Mode..."
# Create necessary directories
mkdir -p nginx/conf.d
mkdir -p uploads
# Build and start services
docker-compose down
docker-compose build --no-cache
docker-compose up -d
echo "Application started!"
echo "Access the application at: http://localhost"
echo ""
echo "To view logs: docker-compose logs -f"
echo "To stop: docker-compose down"

344
server/index.js Normal file
View File

@ -0,0 +1,344 @@
import express from 'express';
import cors from 'cors';
import { createClient } from '@libsql/client';
import multer from 'multer';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors());
app.use(express.json());
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '../public')));
}
// Serve uploads
app.use('/uploads', express.static('uploads'));
// Ensure uploads directory exists
const uploadsDir = 'uploads';
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir);
}
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/');
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({
storage: storage,
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed!'), false);
}
},
limits: {
fileSize: 5 * 1024 * 1024 // 5MB limit
}
});
// Initialize libsql database
const db = createClient({
url: 'file:lawn_scheduler.db'
});
// Create zones table with area field
await db.execute(`
CREATE TABLE IF NOT EXISTS zones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
imagePath TEXT,
lastMowedDate TEXT,
intervalDays INTEGER NOT NULL DEFAULT 7,
area REAL DEFAULT 0,
createdAt TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
// Add area column to existing table if it doesn't exist
try {
await db.execute(`ALTER TABLE zones ADD COLUMN area REAL DEFAULT 0`);
} catch (error) {
// Column already exists, ignore error
}
// Insert sample data if table is empty
const countResult = await db.execute('SELECT COUNT(*) as count FROM zones');
const count = countResult.rows[0].count;
if (count === 0) {
const today = new Date();
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
const twoWeeksAgo = new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000);
await db.execute({
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)',
args: ['Front Yard', weekAgo.toISOString(), 7, 150.5]
});
await db.execute({
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)',
args: ['Back Yard', twoWeeksAgo.toISOString(), 10, 280.0]
});
await db.execute({
sql: 'INSERT INTO zones (name, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?)',
args: ['Side Garden', today.toISOString(), 14, 75.25]
});
}
// Helper function to calculate zone status
function calculateZoneStatus(zone) {
const today = new Date();
const lastMowed = new Date(zone.lastMowedDate);
const daysSinceLastMow = Math.floor((today - lastMowed) / (1000 * 60 * 60 * 24));
const daysUntilNext = zone.intervalDays - daysSinceLastMow;
let status = 'ok';
if (daysUntilNext < 0) {
status = 'overdue';
} else if (daysUntilNext <= 0) {
status = 'due';
} else if (daysUntilNext <= 1) {
status = 'due';
}
return {
...zone,
daysSinceLastMow,
daysUntilNext,
status,
isOverdue: daysUntilNext < 0,
isDueToday: daysUntilNext <= 0 && daysUntilNext >= -1
};
}
// Routes
app.get('/api/zones', async (req, res) => {
try {
const result = await db.execute('SELECT * FROM zones ORDER BY name');
const zones = result.rows.map(row => ({
id: row.id,
name: row.name,
imagePath: row.imagePath,
lastMowedDate: row.lastMowedDate,
intervalDays: row.intervalDays,
area: row.area || 0,
createdAt: row.createdAt
}));
const zonesWithStatus = zones.map(calculateZoneStatus);
res.json(zonesWithStatus);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/zones/:id', async (req, res) => {
try {
const result = await db.execute({
sql: 'SELECT * FROM zones WHERE id = ?',
args: [req.params.id]
});
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Zone not found' });
}
const zone = {
id: result.rows[0].id,
name: result.rows[0].name,
imagePath: result.rows[0].imagePath,
lastMowedDate: result.rows[0].lastMowedDate,
intervalDays: result.rows[0].intervalDays,
area: result.rows[0].area || 0,
createdAt: result.rows[0].createdAt
};
const zoneWithStatus = calculateZoneStatus(zone);
res.json(zoneWithStatus);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/zones', upload.single('image'), async (req, res) => {
try {
const { name, intervalDays, area } = req.body;
const imagePath = req.file ? `/uploads/${req.file.filename}` : null;
const lastMowedDate = new Date().toISOString();
const result = await db.execute({
sql: 'INSERT INTO zones (name, imagePath, lastMowedDate, intervalDays, area) VALUES (?, ?, ?, ?, ?)',
args: [name, imagePath, lastMowedDate, parseInt(intervalDays), parseFloat(area) || 0]
});
const newZoneResult = await db.execute({
sql: 'SELECT * FROM zones WHERE id = ?',
args: [result.lastInsertRowid]
});
const newZone = {
id: newZoneResult.rows[0].id,
name: newZoneResult.rows[0].name,
imagePath: newZoneResult.rows[0].imagePath,
lastMowedDate: newZoneResult.rows[0].lastMowedDate,
intervalDays: newZoneResult.rows[0].intervalDays,
area: newZoneResult.rows[0].area || 0,
createdAt: newZoneResult.rows[0].createdAt
};
const zoneWithStatus = calculateZoneStatus(newZone);
res.status(201).json(zoneWithStatus);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.put('/api/zones/:id', upload.single('image'), async (req, res) => {
try {
const { name, intervalDays, lastMowedDate, area } = req.body;
const existingResult = await db.execute({
sql: 'SELECT * FROM zones WHERE id = ?',
args: [req.params.id]
});
if (existingResult.rows.length === 0) {
return res.status(404).json({ error: 'Zone not found' });
}
const existingZone = existingResult.rows[0];
const imagePath = req.file ? `/uploads/${req.file.filename}` : existingZone.imagePath;
await db.execute({
sql: 'UPDATE zones SET name = ?, imagePath = ?, lastMowedDate = ?, intervalDays = ?, area = ? WHERE id = ?',
args: [name, imagePath, lastMowedDate, parseInt(intervalDays), parseFloat(area) || 0, req.params.id]
});
// Delete old image if new one was provided
if (req.file && existingZone.imagePath) {
const oldImagePath = path.join(process.cwd(), existingZone.imagePath.substring(1));
if (fs.existsSync(oldImagePath)) {
fs.unlinkSync(oldImagePath);
}
}
const updatedResult = await db.execute({
sql: 'SELECT * FROM zones WHERE id = ?',
args: [req.params.id]
});
const updatedZone = {
id: updatedResult.rows[0].id,
name: updatedResult.rows[0].name,
imagePath: updatedResult.rows[0].imagePath,
lastMowedDate: updatedResult.rows[0].lastMowedDate,
intervalDays: updatedResult.rows[0].intervalDays,
area: updatedResult.rows[0].area || 0,
createdAt: updatedResult.rows[0].createdAt
};
const zoneWithStatus = calculateZoneStatus(updatedZone);
res.json(zoneWithStatus);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.delete('/api/zones/:id', async (req, res) => {
try {
const result = await db.execute({
sql: 'SELECT * FROM zones WHERE id = ?',
args: [req.params.id]
});
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Zone not found' });
}
const zone = result.rows[0];
// Delete associated image
if (zone.imagePath) {
const imagePath = path.join(process.cwd(), zone.imagePath.substring(1));
if (fs.existsSync(imagePath)) {
fs.unlinkSync(imagePath);
}
}
await db.execute({
sql: 'DELETE FROM zones WHERE id = ?',
args: [req.params.id]
});
res.status(204).send();
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/zones/:id/mow', async (req, res) => {
try {
const today = new Date().toISOString();
await db.execute({
sql: 'UPDATE zones SET lastMowedDate = ? WHERE id = ?',
args: [today, req.params.id]
});
const updatedResult = await db.execute({
sql: 'SELECT * FROM zones WHERE id = ?',
args: [req.params.id]
});
const updatedZone = {
id: updatedResult.rows[0].id,
name: updatedResult.rows[0].name,
imagePath: updatedResult.rows[0].imagePath,
lastMowedDate: updatedResult.rows[0].lastMowedDate,
intervalDays: updatedResult.rows[0].intervalDays,
area: updatedResult.rows[0].area || 0,
createdAt: updatedResult.rows[0].createdAt
};
const zoneWithStatus = calculateZoneStatus(updatedZone);
res.json(zoneWithStatus);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Serve React app in production
if (process.env.NODE_ENV === 'production') {
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});
}
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString() });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on http://0.0.0.0:${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
});

8
src/App.tsx Normal file
View File

@ -0,0 +1,8 @@
import React from 'react';
import Dashboard from './components/Dashboard';
function App() {
return <Dashboard />;
}
export default App;

View File

@ -0,0 +1,355 @@
import React, { useState, useEffect } from 'react';
import { Plus, Filter, Calendar, Scissors, AlertTriangle, Square, CheckCircle, Clock, Map } from 'lucide-react';
import { Zone } from '../types/zone';
import { api } from '../services/api';
import ZoneCard from './ZoneCard';
import ZoneForm from './ZoneForm';
import SitePlan from './SitePlan';
type FilterType = 'all' | 'due' | 'overdue';
type ViewType = 'dashboard' | 'sitePlan';
const Dashboard: React.FC = () => {
const [zones, setZones] = useState<Zone[]>([]);
const [filteredZones, setFilteredZones] = useState<Zone[]>([]);
const [filter, setFilter] = useState<FilterType>('all');
const [view, setView] = useState<ViewType>('dashboard');
const [showForm, setShowForm] = useState(false);
const [editingZone, setEditingZone] = useState<Zone | null>(null);
const [selectedZoneId, setSelectedZoneId] = useState<number | undefined>();
const [loading, setLoading] = useState(true);
useEffect(() => {
loadZones();
}, []);
useEffect(() => {
applyFilter();
}, [zones, filter]);
const loadZones = async () => {
try {
const data = await api.getZones();
setZones(data);
} catch (error) {
console.error('Failed to load zones:', error);
} finally {
setLoading(false);
}
};
const applyFilter = () => {
let filtered = zones;
switch (filter) {
case 'due':
filtered = zones.filter(zone => zone.isDueToday);
break;
case 'overdue':
filtered = zones.filter(zone => zone.isOverdue);
break;
default:
filtered = zones;
}
setFilteredZones(filtered);
};
const handleMarkAsMowed = async (id: number) => {
try {
await api.markAsMowed(id);
loadZones();
} catch (error) {
console.error('Failed to mark as mowed:', error);
}
};
const handleDeleteZone = async (id: number) => {
if (window.confirm('Are you sure you want to delete this zone?')) {
try {
await api.deleteZone(id);
loadZones();
if (selectedZoneId === id) {
setSelectedZoneId(undefined);
}
} catch (error) {
console.error('Failed to delete zone:', error);
}
}
};
const handleFormSubmit = async () => {
setShowForm(false);
setEditingZone(null);
loadZones();
};
const handleZoneSelect = (zone: Zone) => {
setSelectedZoneId(zone.id);
setView('dashboard');
// Scroll to the zone card
setTimeout(() => {
const element = document.getElementById(`zone-card-${zone.id}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
};
// Calculate area statistics
const overdueCount = zones.filter(zone => zone.isOverdue).length;
const dueCount = zones.filter(zone => zone.isDueToday).length;
const okCount = zones.filter(zone => zone.status === 'ok').length;
const totalArea = zones.reduce((sum, zone) => sum + zone.area, 0);
// Calculate mowed vs remaining area based on status
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')
.reduce((sum, zone) => sum + zone.area, 0);
const mowedPercentage = totalArea > 0 ? (mowedArea / totalArea) * 100 : 0;
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
<Scissors className="h-8 w-8 text-green-600" />
Lawn Care Manager
</h1>
<p className="mt-2 text-gray-600">Keep track of your lawn mowing schedule</p>
</div>
<div className="flex items-center gap-3">
{/* View Toggle */}
<div className="flex bg-gray-100 rounded-lg p-1">
<button
onClick={() => setView('dashboard')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
view === 'dashboard'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Calendar className="h-4 w-4 inline mr-2" />
Dashboard
</button>
<button
onClick={() => setView('sitePlan')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
view === 'sitePlan'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Map className="h-4 w-4 inline mr-2" />
Site Plan
</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"
>
<Plus className="h-5 w-5" />
Add Zone
</button>
</div>
</div>
</div>
{view === 'sitePlan' ? (
<SitePlan
zones={zones}
onZoneSelect={handleZoneSelect}
selectedZoneId={selectedZoneId}
/>
) : (
<>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-6 mb-8">
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-green-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Zones</p>
<p className="text-2xl font-bold text-gray-900">{zones.length}</p>
</div>
<Calendar className="h-8 w-8 text-green-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-purple-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Area</p>
<p className="text-2xl font-bold text-gray-900">{totalArea.toLocaleString()}</p>
<p className="text-xs text-gray-500">sq ft</p>
</div>
<Square className="h-8 w-8 text-purple-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-emerald-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Mowed Area</p>
<p className="text-2xl font-bold text-gray-900">{mowedArea.toLocaleString()}</p>
<p className="text-xs text-gray-500">sq ft ({mowedPercentage.toFixed(1)}%)</p>
</div>
<CheckCircle className="h-8 w-8 text-emerald-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-amber-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Needs Mowing</p>
<p className="text-2xl font-bold text-gray-900">{remainingArea.toLocaleString()}</p>
<p className="text-xs text-gray-500">sq ft ({(100 - mowedPercentage).toFixed(1)}%)</p>
</div>
<Clock className="h-8 w-8 text-amber-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-orange-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Due Today</p>
<p className="text-2xl font-bold text-gray-900">{dueCount}</p>
</div>
<Scissors className="h-8 w-8 text-orange-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-red-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Overdue</p>
<p className="text-2xl font-bold text-gray-900">{overdueCount}</p>
</div>
<AlertTriangle className="h-8 w-8 text-red-500" />
</div>
</div>
</div>
{/* Progress Bar */}
{totalArea > 0 && (
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Mowing Progress</h3>
<span className="text-sm text-gray-600">
{mowedArea.toLocaleString()} / {totalArea.toLocaleString()} sq ft
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-4 mb-2">
<div
className="bg-gradient-to-r from-green-500 to-emerald-600 h-4 rounded-full transition-all duration-500 ease-out"
style={{ width: `${mowedPercentage}%` }}
></div>
</div>
<div className="flex justify-between text-sm text-gray-600">
<span className="flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-emerald-500" />
{mowedPercentage.toFixed(1)}% Complete
</span>
<span className="flex items-center gap-1">
<Clock className="h-4 w-4 text-amber-500" />
{(100 - mowedPercentage).toFixed(1)}% Remaining
</span>
</div>
</div>
)}
{/* Filter Buttons */}
<div className="mb-6">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-gray-500" />
<span className="text-sm font-medium text-gray-700">Filter:</span>
<div className="flex gap-2">
{[
{ key: 'all' as FilterType, label: 'All Zones', count: zones.length },
{ key: 'due' as FilterType, label: 'Due Today', count: dueCount },
{ key: 'overdue' as FilterType, label: 'Overdue', count: overdueCount },
].map(({ key, label, count }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors duration-200 ${
filter === key
? 'bg-green-600 text-white shadow-md'
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
}`}
>
{label} ({count})
</button>
))}
</div>
</div>
</div>
{/* Zones Grid */}
{filteredZones.length === 0 ? (
<div className="text-center py-12">
<Scissors className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No zones found</h3>
<p className="mt-1 text-sm text-gray-500">
{filter === 'all' ? 'Get started by adding your first lawn zone.' : `No zones match the "${filter}" filter.`}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredZones.map(zone => (
<div
key={zone.id}
id={`zone-card-${zone.id}`}
className={`transition-all duration-300 ${
selectedZoneId === zone.id
? 'ring-4 ring-blue-300 ring-opacity-50 scale-105'
: ''
}`}
>
<ZoneCard
zone={zone}
onMarkAsMowed={handleMarkAsMowed}
onEdit={(zone) => {
setEditingZone(zone);
setShowForm(true);
}}
onDelete={handleDeleteZone}
/>
</div>
))}
</div>
)}
</>
)}
{/* Zone Form Modal */}
{showForm && (
<ZoneForm
zone={editingZone}
onSubmit={handleFormSubmit}
onCancel={() => {
setShowForm(false);
setEditingZone(null);
}}
/>
)}
</div>
</div>
);
};
export default Dashboard;

304
src/components/SitePlan.tsx Normal file
View File

@ -0,0 +1,304 @@
import React, { useState, useRef, useEffect } from 'react';
import { MapPin, Upload, X, Eye, EyeOff } from 'lucide-react';
import { Zone } from '../types/zone';
interface ZoneMarker {
id: number;
x: number; // Percentage from left
y: number; // Percentage from top
}
interface SitePlanProps {
zones: Zone[];
onZoneSelect: (zone: Zone) => void;
selectedZoneId?: number;
}
const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId }) => {
const [sitePlanImage, setSitePlanImage] = useState<string | null>(
localStorage.getItem('sitePlanImage')
);
const [zoneMarkers, setZoneMarkers] = useState<ZoneMarker[]>(() => {
const saved = localStorage.getItem('zoneMarkers');
return saved ? JSON.parse(saved) : [];
});
const [isEditMode, setIsEditMode] = useState(false);
const [showMarkers, setShowMarkers] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
// Save markers to localStorage whenever they change
useEffect(() => {
localStorage.setItem('zoneMarkers', JSON.stringify(zoneMarkers));
}, [zoneMarkers]);
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const imageUrl = e.target?.result as string;
setSitePlanImage(imageUrl);
localStorage.setItem('sitePlanImage', imageUrl);
};
reader.readAsDataURL(file);
}
};
const handleImageClick = (e: React.MouseEvent<HTMLImageElement>) => {
if (!isEditMode || !imageRef.current) return;
const rect = imageRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
// Find a zone that doesn't have a marker yet
const unmarkedZone = zones.find(zone =>
!zoneMarkers.some(marker => marker.id === zone.id)
);
if (unmarkedZone) {
const newMarker: ZoneMarker = {
id: unmarkedZone.id,
x,
y
};
setZoneMarkers(prev => [...prev, newMarker]);
}
};
const removeMarker = (zoneId: number) => {
setZoneMarkers(prev => prev.filter(marker => marker.id !== zoneId));
};
const clearSitePlan = () => {
if (window.confirm('Are you sure you want to remove the site plan and all markers?')) {
setSitePlanImage(null);
setZoneMarkers([]);
localStorage.removeItem('sitePlanImage');
localStorage.removeItem('zoneMarkers');
setIsEditMode(false);
}
};
const getZoneById = (id: number) => zones.find(zone => zone.id === id);
const getMarkerColor = (zone: Zone) => {
switch (zone.status) {
case 'overdue':
return 'bg-red-500 border-red-600 shadow-red-200';
case 'due':
return 'bg-orange-500 border-orange-600 shadow-orange-200';
default:
return 'bg-green-500 border-green-600 shadow-green-200';
}
};
if (!sitePlanImage) {
return (
<div className="bg-white rounded-lg shadow-md p-8">
<div className="text-center">
<div className="mb-6">
<MapPin className="mx-auto h-16 w-16 text-gray-400" />
<h3 className="mt-4 text-lg font-semibold text-gray-900">Site Plan</h3>
<p className="mt-2 text-gray-600">
Upload an image of your property to visualize zone locations
</p>
</div>
<button
onClick={() => fileInputRef.current?.click()}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 mx-auto transition-colors duration-200"
>
<Upload className="h-5 w-5" />
Upload Site Plan
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
<p className="mt-4 text-sm text-gray-500">
Supported formats: JPG, PNG, GIF (max 10MB)
</p>
</div>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-md overflow-hidden">
{/* Header */}
<div className="p-4 border-b bg-gray-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<MapPin className="h-6 w-6 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900">Site Plan</h3>
<span className="text-sm text-gray-500">
({zoneMarkers.length} of {zones.length} zones marked)
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowMarkers(!showMarkers)}
className={`p-2 rounded-md transition-colors duration-200 ${
showMarkers
? 'bg-blue-100 text-blue-600'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
title={showMarkers ? 'Hide markers' : 'Show markers'}
>
{showMarkers ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<button
onClick={() => setIsEditMode(!isEditMode)}
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
isEditMode
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{isEditMode ? 'Done Editing' : 'Edit Markers'}
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="px-3 py-2 bg-blue-100 text-blue-700 rounded-md text-sm font-medium hover:bg-blue-200 transition-colors duration-200"
>
Change Image
</button>
<button
onClick={clearSitePlan}
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors duration-200"
title="Remove site plan"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{isEditMode && (
<div className="mt-3 p-3 bg-blue-50 rounded-md">
<p className="text-sm text-blue-800">
<strong>Edit Mode:</strong> Click on the image to place markers for zones.
Zones without markers: {zones.filter(zone => !zoneMarkers.some(marker => marker.id === zone.id)).map(zone => zone.name).join(', ') || 'None'}
</p>
</div>
)}
</div>
{/* Site Plan Image */}
<div className="relative">
<img
ref={imageRef}
src={sitePlanImage}
alt="Site Plan"
className={`w-full h-auto max-h-[600px] object-contain ${
isEditMode ? 'cursor-crosshair' : 'cursor-default'
}`}
onClick={handleImageClick}
/>
{/* Zone Markers */}
{showMarkers && zoneMarkers.map(marker => {
const zone = getZoneById(marker.id);
if (!zone) return null;
return (
<div
key={marker.id}
className="absolute transform -translate-x-1/2 -translate-y-1/2 group"
style={{
left: `${marker.x}%`,
top: `${marker.y}%`
}}
>
{/* Marker */}
<button
onClick={(e) => {
e.stopPropagation();
if (!isEditMode) {
onZoneSelect(zone);
}
}}
className={`w-6 h-6 rounded-full border-2 shadow-lg transition-all duration-200 hover:scale-110 ${
getMarkerColor(zone)
} ${
selectedZoneId === zone.id
? 'ring-4 ring-blue-300 scale-110'
: ''
} ${
isEditMode ? 'cursor-pointer' : 'cursor-pointer hover:shadow-xl'
}`}
>
<span className="sr-only">{zone.name}</span>
</button>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10">
<div className="bg-gray-900 text-white text-xs rounded-md px-2 py-1 whitespace-nowrap">
<div className="font-medium">{zone.name}</div>
<div className="text-gray-300">
{zone.isOverdue ? 'Overdue' : zone.isDueToday ? 'Due today' : `${zone.daysUntilNext} days`}
</div>
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
</div>
</div>
{/* Remove button in edit mode */}
{isEditMode && (
<button
onClick={(e) => {
e.stopPropagation();
removeMarker(zone.id);
}}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 transition-colors duration-200 flex items-center justify-center"
>
×
</button>
)}
</div>
);
})}
</div>
{/* Legend */}
{showMarkers && zoneMarkers.length > 0 && (
<div className="p-4 border-t bg-gray-50">
<h4 className="text-sm font-medium text-gray-900 mb-3">Zone Status Legend</h4>
<div className="flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500 border border-green-600"></div>
<span className="text-gray-700">Up to date</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-orange-500 border border-orange-600"></div>
<span className="text-gray-700">Due today</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500 border border-red-600"></div>
<span className="text-gray-700">Overdue</span>
</div>
</div>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
</div>
);
};
export default SitePlan;

143
src/components/ZoneCard.tsx Normal file
View File

@ -0,0 +1,143 @@
import React from 'react';
import { Scissors, Edit, Trash2, Calendar, Camera, Square } from 'lucide-react';
import { Zone } from '../types/zone';
interface ZoneCardProps {
zone: Zone;
onMarkAsMowed: (id: number) => void;
onEdit: (zone: Zone) => void;
onDelete: (id: number) => void;
}
const ZoneCard: React.FC<ZoneCardProps> = ({
zone,
onMarkAsMowed,
onEdit,
onDelete,
}) => {
const getStatusColor = (status: string) => {
switch (status) {
case 'overdue':
return 'border-red-500 bg-red-50';
case 'due':
return 'border-orange-500 bg-orange-50';
default:
return 'border-green-500 bg-green-50';
}
};
const getStatusText = (zone: Zone) => {
if (zone.isOverdue) {
return `${Math.abs(zone.daysUntilNext)} days overdue`;
} else if (zone.isDueToday) {
return 'Due today';
} else {
return `${zone.daysUntilNext} days remaining`;
}
};
const getStatusTextColor = (status: string) => {
switch (status) {
case 'overdue':
return 'text-red-700';
case 'due':
return 'text-orange-700';
default:
return 'text-green-700';
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const formatArea = (area: number) => {
if (area === 0) return 'Not specified';
return `${area.toLocaleString()} sq ft`;
};
return (
<div className={`bg-white rounded-lg shadow-md border-l-4 ${getStatusColor(zone.status)} hover:shadow-lg transition-shadow duration-200`}>
{/* Zone Image */}
{zone.imagePath ? (
<div className="relative h-48 overflow-hidden rounded-t-lg">
<img
src={`http://localhost:3001${zone.imagePath}`}
alt={zone.name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black bg-opacity-20"></div>
</div>
) : (
<div className="h-48 bg-gray-100 rounded-t-lg flex items-center justify-center">
<div className="text-center text-gray-400">
<Camera className="h-12 w-12 mx-auto mb-2" />
<p className="text-sm">No image</p>
</div>
</div>
)}
<div className="p-6">
{/* Zone Name and Status */}
<div className="mb-4">
<h3 className="text-lg font-semibold text-gray-900 mb-2">{zone.name}</h3>
<div className={`text-sm font-medium ${getStatusTextColor(zone.status)}`}>
{getStatusText(zone)}
</div>
</div>
{/* Zone Details */}
<div className="space-y-2 mb-4 text-sm text-gray-600">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
<span>Last mowed: {formatDate(zone.lastMowedDate)}</span>
</div>
<div className="flex items-center gap-2">
<Scissors className="h-4 w-4" />
<span>Every {zone.intervalDays} days</span>
</div>
<div className="flex items-center gap-2">
<Square className="h-4 w-4" />
<span>Area: {formatArea(zone.area)}</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<button
onClick={() => onMarkAsMowed(zone.id)}
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'
: 'bg-green-600 hover:bg-green-700 text-white shadow-sm hover:shadow-md'
}`}
>
<Scissors className="h-4 w-4 inline mr-1" />
Mowed
</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>
</div>
</div>
);
};
export default ZoneCard;

216
src/components/ZoneForm.tsx Normal file
View File

@ -0,0 +1,216 @@
import React, { useState, useRef, useEffect } from 'react';
import { X, Upload, Camera } from 'lucide-react';
import { Zone, ZoneFormData } from '../types/zone';
import { api } from '../services/api';
interface ZoneFormProps {
zone: Zone | null;
onSubmit: () => void;
onCancel: () => void;
}
const ZoneForm: React.FC<ZoneFormProps> = ({ zone, onSubmit, onCancel }) => {
const [formData, setFormData] = useState<ZoneFormData>({
name: zone?.name || '',
intervalDays: zone?.intervalDays || 7,
lastMowedDate: zone ? zone.lastMowedDate.split('T')[0] : new Date().toISOString().split('T')[0],
area: zone?.area || 0,
});
const [imagePreview, setImagePreview] = useState<string | null>(
zone?.imagePath ? `http://localhost:3001${zone.imagePath}` : null
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setFormData(prev => ({ ...prev, image: file }));
const reader = new FileReader();
reader.onload = (e) => {
setImagePreview(e.target?.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
if (zone) {
await api.updateZone(zone.id, formData);
} else {
await api.createZone(formData);
}
onSubmit();
} catch (error) {
setError(error instanceof Error ? error.message : 'An error occurred');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900">
{zone ? 'Edit Zone' : 'Add New Zone'}
</h2>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600 transition-colors duration-200"
>
<X className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
{/* Zone Name */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
Zone Name
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: 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"
required
placeholder="e.g., Front Yard, Back Garden"
/>
</div>
{/* Area */}
<div>
<label htmlFor="area" className="block text-sm font-medium text-gray-700 mb-2">
Area (square feet)
</label>
<input
type="number"
id="area"
value={formData.area}
onChange={(e) => setFormData(prev => ({ ...prev, area: parseFloat(e.target.value) || 0 }))}
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="e.g., 150.5"
/>
<p className="text-xs text-gray-500 mt-1">Optional - helps track total lawn area</p>
</div>
{/* Mowing Interval */}
<div>
<label htmlFor="intervalDays" className="block text-sm font-medium text-gray-700 mb-2">
Mowing Interval (days)
</label>
<input
type="number"
id="intervalDays"
value={formData.intervalDays}
onChange={(e) => setFormData(prev => ({ ...prev, intervalDays: parseInt(e.target.value) }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
required
min="1"
max="365"
/>
</div>
{/* Last Mowed Date */}
<div>
<label htmlFor="lastMowedDate" className="block text-sm font-medium text-gray-700 mb-2">
Last Mowed Date
</label>
<input
type="date"
id="lastMowedDate"
value={formData.lastMowedDate}
onChange={(e) => setFormData(prev => ({ ...prev, lastMowedDate: 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"
required
/>
</div>
{/* Zone Image */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Zone Image (optional)
</label>
{imagePreview ? (
<div className="relative">
<img
src={imagePreview}
alt="Zone preview"
className="w-full h-48 object-cover rounded-md border"
/>
<button
type="button"
onClick={() => {
setImagePreview(null);
setFormData(prev => ({ ...prev, image: undefined }));
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}}
className="absolute top-2 right-2 bg-red-500 text-white p-1.5 rounded-full hover:bg-red-600 transition-colors duration-200"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-gray-300 rounded-md p-6 text-center cursor-pointer hover:border-green-500 hover:bg-green-50 transition-colors duration-200"
>
<Upload className="mx-auto h-8 w-8 text-gray-400 mb-2" />
<p className="text-sm text-gray-600">Click to upload zone image</p>
<p className="text-xs text-gray-500 mt-1">PNG, JPG up to 5MB</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageChange}
className="hidden"
/>
</div>
{/* Form Actions */}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onCancel}
className="flex-1 py-2 px-4 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors duration-200"
>
Cancel
</button>
<button
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"
>
{loading ? 'Saving...' : zone ? 'Update Zone' : 'Create Zone'}
</button>
</div>
</form>
</div>
</div>
);
};
export default ZoneForm;

3
src/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

67
src/services/api.ts Normal file
View File

@ -0,0 +1,67 @@
import { Zone, ZoneFormData } from '../types/zone';
const API_BASE = 'http://localhost:3001/api';
export const api = {
async getZones(): Promise<Zone[]> {
const response = await fetch(`${API_BASE}/zones`);
if (!response.ok) throw new Error('Failed to fetch zones');
return response.json();
},
async getZone(id: number): Promise<Zone> {
const response = await fetch(`${API_BASE}/zones/${id}`);
if (!response.ok) throw new Error('Failed to fetch zone');
return response.json();
},
async createZone(data: ZoneFormData): Promise<Zone> {
const formData = new FormData();
formData.append('name', data.name);
formData.append('intervalDays', data.intervalDays.toString());
formData.append('area', data.area.toString());
if (data.image) {
formData.append('image', data.image);
}
const response = await fetch(`${API_BASE}/zones`, {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Failed to create zone');
return response.json();
},
async updateZone(id: number, data: ZoneFormData): Promise<Zone> {
const formData = new FormData();
formData.append('name', data.name);
formData.append('intervalDays', data.intervalDays.toString());
formData.append('lastMowedDate', data.lastMowedDate);
formData.append('area', data.area.toString());
if (data.image) {
formData.append('image', data.image);
}
const response = await fetch(`${API_BASE}/zones/${id}`, {
method: 'PUT',
body: formData,
});
if (!response.ok) throw new Error('Failed to update zone');
return response.json();
},
async deleteZone(id: number): Promise<void> {
const response = await fetch(`${API_BASE}/zones/${id}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete zone');
},
async markAsMowed(id: number): Promise<Zone> {
const response = await fetch(`${API_BASE}/zones/${id}/mow`, {
method: 'POST',
});
if (!response.ok) throw new Error('Failed to mark as mowed');
return response.json();
},
};

22
src/types/zone.ts Normal file
View File

@ -0,0 +1,22 @@
export interface Zone {
id: number;
name: string;
imagePath?: string;
lastMowedDate: string;
intervalDays: number;
area: number;
createdAt: string;
daysSinceLastMow: number;
daysUntilNext: number;
status: 'ok' | 'due' | 'overdue';
isOverdue: boolean;
isDueToday: boolean;
}
export interface ZoneFormData {
name: string;
intervalDays: number;
lastMowedDate: string;
area: number;
image?: File;
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

8
tailwind.config.js Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};

24
tsconfig.app.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

18
vite.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
});