Первый комит

This commit is contained in:
anibilag 2025-07-28 22:22:46 +03:00
commit 9d154596ff
37 changed files with 11038 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.

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

26
Dockerfile.frontend Normal file
View File

@ -0,0 +1,26 @@
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Serve the application
FROM nginx:alpine
COPY --from=0 /app/dist /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]

171
README.md Normal file
View File

@ -0,0 +1,171 @@
# GardenTrack 🌱
A comprehensive full-stack garden plant and maintenance tracking system built with React.js, TypeScript, Express.js, and SQLite.
## Features
### 🌿 Plant Registry Module
- Add and manage all plants in your garden (trees, shrubs, herbs, etc.)
- Track plant type, variety/species, planting date, and health status
- Store detailed notes for each plant
- Visual health status indicators
### 📊 Yearly History Tracking
- Year-by-year history of blooming and fruiting dates
- Comprehensive harvest records with quantity and date tracking
- Maintenance activity logs with detailed descriptions
### 🔧 Maintenance Task Logging
- Log chemical treatments, fertilizer applications, and other garden activities
- Track dates, types, descriptions, and amounts used
- Categorized maintenance types for easy filtering
### 📅 Task Planning and Reminders
- Create future tasks with titles, descriptions, and deadlines
- Visual dashboard showing upcoming and overdue tasks
- Mark tasks as completed with progress tracking
### 🎨 Additional Features
- **Responsive Design**: Optimized for desktop, tablet, and mobile devices
- **Search & Filter**: Find plants and records quickly
- **Modern UI**: Clean, nature-inspired interface with smooth animations
- **Dashboard Overview**: At-a-glance view of garden status and upcoming tasks
## Technology Stack
### Frontend
- **React.js 18** with TypeScript
- **Tailwind CSS** for styling
- **Lucide React** for icons
- **Vite** for development and building
### Backend
- **Express.js** REST API
- **SQLite** database
- **CORS** enabled for cross-origin requests
### Deployment
- **Docker Compose** setup with multi-container architecture
- **Nginx** reverse proxy for production
- **Containerized** frontend and backend services
## Getting Started
### Prerequisites
- Node.js 18+ and npm
- Docker and Docker Compose (for containerized deployment)
### Development Setup
1. **Clone and install dependencies:**
```bash
# Install frontend dependencies
npm install
# Install backend dependencies
cd backend
npm install
```
2. **Initialize the database:**
```bash
cd backend
npm run init-db
```
3. **Start development servers:**
```bash
# Start backend (in backend directory)
npm run dev
# Start frontend (in root directory)
npm run dev
```
4. **Access the application:**
- Frontend: http://localhost:5173
- Backend API: http://localhost:3001
### Docker Deployment
1. **Build and start services:**
```bash
docker-compose up --build
```
2. **Initialize database:**
```bash
docker-compose run db-init
```
3. **Access the application:**
- Application: http://localhost:3000
- API: http://localhost:3001
## API Endpoints
### Plants
- `GET /api/plants` - Get all plants
- `POST /api/plants` - Create new plant
- `PUT /api/plants/:id` - Update plant
- `DELETE /api/plants/:id` - Delete plant
### Tasks
- `GET /api/tasks` - Get all tasks
- `POST /api/tasks` - Create new task
- `PUT /api/tasks/:id` - Update task
- `DELETE /api/tasks/:id` - Delete task
### Maintenance Records
- `GET /api/maintenance` - Get all maintenance records
- `POST /api/maintenance` - Create maintenance record
### Harvest Records
- `GET /api/harvests` - Get all harvest records
- `POST /api/harvests` - Create harvest record
## Database Schema
The application uses SQLite with the following main tables:
- **plants** - Store plant information
- **tasks** - Task planning and tracking
- **maintenance_records** - Maintenance activity logs
- **harvest_records** - Harvest tracking
- **plant_history** - Year-by-year plant history
## Project Structure
```
gardentrack/
├── src/ # Frontend source code
│ ├── components/ # React components
│ ├── services/ # API services
│ ├── types/ # TypeScript types
│ └── App.tsx # Main application component
├── backend/ # Backend source code
│ ├── scripts/ # Database initialization
│ ├── database/ # SQLite database files
│ └── server.js # Express server
├── docker-compose.yml # Docker services configuration
└── README.md # Project documentation
```
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Support
For support, please open an issue in the GitHub repository or contact the development team.
---
**Happy Gardening! 🌱**

19
backend/Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# Create database directory
RUN mkdir -p database
EXPOSE 3001
CMD ["node", "server.js"]

2684
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
backend/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "gardentrack-backend",
"version": "1.0.0",
"description": "Backend API for GardenTrack application",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"init-db": "node scripts/init-db.js",
"reset-db": "rm -f database/gardentrack.db && node server.js"
},
"dependencies": {
"express": "^4.18.2",
"sqlite3": "^5.1.6",
"cors": "^2.8.5",
"body-parser": "^1.20.2",
"dotenv": "^16.3.1"
},
"devDependencies": {
"nodemon": "^3.0.1"
},
"keywords": ["garden", "plants", "tracking", "maintenance"],
"author": "GardenTrack Team",
"license": "MIT"
}

142
backend/scripts/init-db.js Normal file
View File

@ -0,0 +1,142 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
const DB_DIR = path.join(__dirname, '..', 'database');
const DB_PATH = path.join(DB_DIR, 'gardentrack.db');
// Ensure database directory exists
if (!fs.existsSync(DB_DIR)) {
fs.mkdirSync(DB_DIR, { recursive: true });
}
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.log(DB_PATH);
console.error('Error creating database:', err.message);
process.exit(1);
} else {
console.log('Connected to SQLite database');
}
});
// Create tables
db.serialize(() => {
// Plants table
db.run(`
CREATE TABLE IF NOT EXISTS plants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
purchase_location TEXT,
seedling_age INTEGER,
type TEXT NOT NULL,
variety TEXT NOT NULL,
seedling_height REAL,
planting_date DATE NOT NULL,
current_height REAL,
health_status TEXT DEFAULT 'good' CHECK(health_status IN ('good', 'needs-attention', 'dead')),
photo_url TEXT,
current_photo_url TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Plant history table
db.run(`
CREATE TABLE IF NOT EXISTS plant_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plant_id INTEGER NOT NULL,
year INTEGER NOT NULL,
blooming_date DATE,
fruiting_date DATE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (plant_id) REFERENCES plants (id) ON DELETE CASCADE
)
`);
// Harvest records table
db.run(`
CREATE TABLE IF NOT EXISTS harvest_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plant_id INTEGER NOT NULL,
date DATE NOT NULL,
quantity REAL NOT NULL,
unit TEXT NOT NULL,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (plant_id) REFERENCES plants (id) ON DELETE CASCADE
)
`);
// Maintenance records table
db.run(`
CREATE TABLE IF NOT EXISTS maintenance_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plant_id INTEGER NOT NULL,
date DATE NOT NULL,
type TEXT NOT NULL CHECK(type IN ('chemical', 'fertilizer', 'watering', 'pruning', 'transplanting', 'other')),
description TEXT NOT NULL,
amount TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (plant_id) REFERENCES plants (id) ON DELETE CASCADE
)
`);
// Tasks table
db.run(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plant_id INTEGER,
title TEXT NOT NULL,
description TEXT,
deadline DATE NOT NULL,
completed BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (plant_id) REFERENCES plants (id) ON DELETE SET NULL
)
`);
console.log('Database tables created successfully!');
// Insert sample data
console.log('Inserting sample data...');
// Sample plants
const plantSql = `INSERT INTO plants (purchase_location, seedling_age, type, variety, seedling_height, planting_date, current_height, health_status, photo_url, current_photo_url, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
db.run(plantSql, ['Local Nursery', 12, 'tree', 'Apple - Honeycrisp', 45.0, '2022-04-15', 180.0, 'good', null, null, 'Growing well, good fruit production']);
db.run(plantSql, ['Garden Center', 8, 'shrub', 'Blueberry - Bluecrop', 25.0, '2023-03-20', 85.0, 'needs-attention', null, null, 'Leaves showing slight discoloration']);
db.run(plantSql, ['Online Store', 3, 'herb', 'Basil - Sweet Genovese', 8.0, '2024-05-10', 35.0, 'good', null, null, 'Producing well, regular harvests']);
// Sample tasks
const taskSql = `INSERT INTO tasks (plant_id, title, description, deadline, completed) VALUES (?, ?, ?, ?, ?)`;
db.run(taskSql, [1, 'Apply fertilizer', 'Apply spring fertilizer to apple trees', '2024-03-15', 0]);
db.run(taskSql, [2, 'Prune blueberry bushes', 'Annual pruning before spring growth', '2024-02-28', 1]);
db.run(taskSql, [3, 'Harvest basil', 'Regular harvest to encourage growth', '2024-06-01', 0]);
// Sample maintenance records
const maintenanceSql = `INSERT INTO maintenance_records (plant_id, date, type, description, amount) VALUES (?, ?, ?, ?, ?)`;
db.run(maintenanceSql, [1, '2024-01-10', 'pruning', 'Winter pruning - removed dead branches', null]);
db.run(maintenanceSql, [2, '2024-01-05', 'fertilizer', 'Applied organic compost', '2 cups']);
db.run(maintenanceSql, [3, '2024-05-15', 'watering', 'Deep watering during dry spell', '1 gallon']);
// Sample harvest records
const harvestSql = `INSERT INTO harvest_records (plant_id, date, quantity, unit, notes) VALUES (?, ?, ?, ?, ?)`;
db.run(harvestSql, [1, '2023-09-15', 25, 'lbs', 'Excellent harvest, apples were sweet and crisp']);
db.run(harvestSql, [2, '2023-07-20', 3, 'cups', 'First harvest of the season, berries were plump and sweet']);
db.run(harvestSql, [3, '2024-05-25', 0.5, 'cups', 'Fresh basil for cooking, very aromatic']);
console.log('Sample data inserted successfully!');
});
db.close((err) => {
if (err) {
console.error('Error closing database:', err.message);
} else {
console.log('Database initialization completed!');
}
});

673
backend/server.js Normal file
View File

@ -0,0 +1,673 @@
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 3001;
const DB_DIR = path.join(__dirname, 'database');
const DB_PATH = path.join(DB_DIR, 'gardentrack.db');
// Ensure database directory exists
if (!fs.existsSync(DB_DIR)) {
fs.mkdirSync(DB_DIR, { recursive: true });
console.log('Created database directory');
}
// Middleware
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// Database connection and initialization
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error('Error opening database:', err.message);
process.exit(1);
} else {
console.log('Connected to SQLite database at:', DB_PATH);
initializeDatabase();
}
});
// Initialize database tables
function initializeDatabase() {
console.log('Initializing database tables...');
db.serialize(() => {
// Plants table
db.run(`
CREATE TABLE IF NOT EXISTS plants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
variety TEXT NOT NULL,
purchase_location TEXT NOT NULL,
seedling_age INTEGER NOT NULL,
seedling_height REAL NOT NULL,
planting_date DATE NOT NULL,
health_status TEXT DEFAULT 'good' CHECK(health_status IN ('good', 'needs-attention', 'dead')),
current_height REAL,
photo_url TEXT,
current_photo_url TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, (err) => {
if (err) console.error('Error creating plants table:', err);
else console.log('Plants table ready');
});
// Plant history table
db.run(`
CREATE TABLE IF NOT EXISTS plant_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plant_id INTEGER NOT NULL,
year INTEGER NOT NULL,
blooming_date DATE,
fruiting_date DATE,
harvest_date DATE,
current_height REAL,
current_photo_url TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (plant_id) REFERENCES plants (id) ON DELETE CASCADE
)
`, (err) => {
if (err) console.error('Error creating plant_history table:', err);
else console.log('Plant history table ready');
});
// Harvest records table
db.run(`
CREATE TABLE IF NOT EXISTS harvest_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plant_id INTEGER NOT NULL,
date DATE NOT NULL,
quantity REAL NOT NULL,
unit TEXT NOT NULL,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (plant_id) REFERENCES plants (id) ON DELETE CASCADE
)
`, (err) => {
if (err) console.error('Error creating harvest_records table:', err);
else console.log('Harvest records table ready');
});
// Maintenance records table
db.run(`
CREATE TABLE IF NOT EXISTS maintenance_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plant_id INTEGER NOT NULL,
date DATE NOT NULL,
type TEXT NOT NULL CHECK(type IN ('chemical', 'fertilizer', 'watering', 'pruning', 'transplanting', 'other')),
description TEXT NOT NULL,
amount TEXT,
is_planned BOOLEAN DEFAULT 0,
is_completed BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (plant_id) REFERENCES plants (id) ON DELETE CASCADE
)
`, (err) => {
if (err) console.error('Error creating maintenance_records table:', err);
else console.log('Maintenance records table ready');
});
// Tasks table
db.run(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plant_id INTEGER,
title TEXT NOT NULL,
description TEXT,
deadline DATE NOT NULL,
completed BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (plant_id) REFERENCES plants (id) ON DELETE SET NULL
)
`, (err) => {
if (err) console.error('Error creating tasks table:', err);
else console.log('Tasks table ready');
});
// Insert sample data if tables are empty
db.get('SELECT COUNT(*) as count FROM plants', (err, row) => {
if (err) {
console.error('Error checking plants table:', err);
return;
}
if (row.count === 0) {
console.log('Inserting sample data...');
insertSampleData();
} else {
console.log('Database already contains data');
}
});
});
}
// Insert sample data
function insertSampleData() {
// Sample plants
const plantSql = `INSERT INTO plants (type, variety, purchase_location, seedling_age, seedling_height, planting_date, health_status, current_height, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
db.run(plantSql, ['tree', 'Apple - Honeycrisp', 'Local Nursery', 12, 45.5, '2022-04-15', 'good', 180.2, 'Growing well, good fruit production']);
db.run(plantSql, ['shrub', 'Blueberry - Bluecrop', 'Garden Center', 8, 25.0, '2023-03-20', 'needs-attention', 65.8, 'Leaves showing slight discoloration']);
db.run(plantSql, ['herb', 'Basil - Sweet Genovese', 'Online Store', 3, 8.2, '2024-05-10', 'good', 22.5, 'Producing well, regular harvests']);
// Sample tasks
const taskSql = `INSERT INTO tasks (plant_id, title, description, deadline, completed) VALUES (?, ?, ?, ?, ?)`;
db.run(taskSql, [1, 'Apply fertilizer', 'Apply spring fertilizer to apple trees', '2024-03-15', 0]);
db.run(taskSql, [2, 'Prune blueberry bushes', 'Annual pruning before spring growth', '2024-02-28', 1]);
db.run(taskSql, [3, 'Harvest basil', 'Regular harvest to encourage growth', '2024-06-01', 0]);
// Sample maintenance records
const maintenanceSql = `INSERT INTO maintenance_records (plant_id, date, type, description, amount, is_planned, is_completed) VALUES (?, ?, ?, ?, ?, ?, ?)`;
db.run(maintenanceSql, [1, '2024-01-10', 'pruning', 'Winter pruning - removed dead branches', null, 0, 1]);
db.run(maintenanceSql, [2, '2024-01-05', 'fertilizer', 'Applied organic compost', '2 cups', 0, 1]);
db.run(maintenanceSql, [3, '2024-05-15', 'watering', 'Deep watering during dry spell', '1 gallon', 0, 1]);
// Sample harvest records
const harvestSql = `INSERT INTO harvest_records (plant_id, date, quantity, unit, notes) VALUES (?, ?, ?, ?, ?)`;
db.run(harvestSql, [1, '2023-09-15', 25, 'lbs', 'Excellent harvest, apples were sweet and crisp']);
db.run(harvestSql, [2, '2023-07-20', 3, 'cups', 'First harvest of the season, berries were plump and sweet']);
db.run(harvestSql, [3, '2024-05-25', 0.5, 'cups', 'Fresh basil for cooking, very aromatic']);
console.log('Sample data inserted successfully!');
}
// Routes
// Plants
app.get('/api/plants', (req, res) => {
const sql = 'SELECT * FROM plants ORDER BY created_at DESC';
db.all(sql, [], (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(rows.map(row => ({
id: row.id,
type: row.type,
variety: row.variety,
purchaseLocation: row.purchase_location,
seedlingAge: row.seedling_age,
seedlingHeight: row.seedling_height,
plantingDate: row.planting_date,
healthStatus: row.health_status,
currentHeight: row.current_height,
photoUrl: row.photo_url,
currentPhotoUrl: row.current_photo_url,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
})));
});
});
app.post('/api/plants', (req, res) => {
const { type, variety, purchaseLocation, seedlingAge, seedlingHeight, plantingDate, healthStatus, currentHeight, photoUrl, notes } = req.body;
const sql = `INSERT INTO plants (type, variety, purchase_location, seedling_age, seedling_height, planting_date, health_status, current_height, photo_url, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
db.run(sql, [type, variety, purchaseLocation, seedlingAge, seedlingHeight, plantingDate, healthStatus, currentHeight, photoUrl, notes], function(err) {
if (err) {
res.status(400).json({ error: err.message });
return;
}
// Get the created plant
db.get('SELECT * FROM plants WHERE id = ?', [this.lastID], (err, row) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json({
id: row.id,
type: row.type,
variety: row.variety,
purchaseLocation: row.purchase_location,
seedlingAge: row.seedling_age,
seedlingHeight: row.seedling_height,
plantingDate: row.planting_date,
healthStatus: row.health_status,
currentHeight: row.current_height,
photoUrl: row.photo_url,
currentPhotoUrl: row.current_photo_url,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
});
});
});
});
app.put('/api/plants/:id', (req, res) => {
const { type, variety, purchaseLocation, seedlingAge, seedlingHeight, plantingDate, healthStatus, currentHeight, photoUrl, notes } = req.body;
const sql = `UPDATE plants SET type = ?, variety = ?, purchase_location = ?, seedling_age = ?, seedling_height = ?, planting_date = ?,
health_status = ?, current_height = ?, photo_url = ?, notes = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`;
db.run(sql, [type, variety, purchaseLocation, seedlingAge, seedlingHeight, plantingDate, healthStatus, currentHeight, photoUrl, notes, req.params.id], function(err) {
if (err) {
res.status(400).json({ error: err.message });
return;
}
// Get the updated plant
db.get('SELECT * FROM plants WHERE id = ?', [req.params.id], (err, row) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json({
id: row.id,
type: row.type,
variety: row.variety,
purchaseLocation: row.purchase_location,
seedlingAge: row.seedling_age,
seedlingHeight: row.seedling_height,
plantingDate: row.planting_date,
healthStatus: row.health_status,
currentHeight: row.current_height,
photoUrl: row.photo_url,
currentPhotoUrl: row.current_photo_url,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
});
});
});
});
app.delete('/api/plants/:id', (req, res) => {
db.run('DELETE FROM plants WHERE id = ?', [req.params.id], function(err) {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json({ message: 'Plant deleted successfully' });
});
});
// Tasks
app.get('/api/tasks', (req, res) => {
const sql = 'SELECT * FROM tasks ORDER BY deadline ASC';
db.all(sql, [], (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(rows.map(row => ({
id: row.id,
plantId: row.plant_id,
title: row.title,
description: row.description,
deadline: row.deadline,
completed: Boolean(row.completed),
createdAt: row.created_at,
updatedAt: row.updated_at
})));
});
});
app.post('/api/tasks', (req, res) => {
const { plantId, title, description, deadline, completed = false } = req.body;
const sql = `INSERT INTO tasks (plant_id, title, description, deadline, completed)
VALUES (?, ?, ?, ?, ?)`;
db.run(sql, [plantId, title, description, deadline, completed ? 1 : 0], function(err) {
if (err) {
res.status(400).json({ error: err.message });
return;
}
db.get('SELECT * FROM tasks WHERE id = ?', [this.lastID], (err, row) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json({
id: row.id,
plantId: row.plant_id,
title: row.title,
description: row.description,
deadline: row.deadline,
completed: Boolean(row.completed),
createdAt: row.created_at,
updatedAt: row.updated_at
});
});
});
});
app.put('/api/tasks/:id', (req, res) => {
const { plantId, title, description, deadline, completed } = req.body;
const sql = `UPDATE tasks SET plant_id = ?, title = ?, description = ?,
deadline = ?, completed = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`;
db.run(sql, [plantId, title, description, deadline, completed ? 1 : 0, req.params.id], function(err) {
if (err) {
res.status(400).json({ error: err.message });
return;
}
db.get('SELECT * FROM tasks WHERE id = ?', [req.params.id], (err, row) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json({
id: row.id,
plantId: row.plant_id,
title: row.title,
description: row.description,
deadline: row.deadline,
completed: Boolean(row.completed),
createdAt: row.created_at,
updatedAt: row.updated_at
});
});
});
});
app.delete('/api/tasks/:id', (req, res) => {
db.run('DELETE FROM tasks WHERE id = ?', [req.params.id], function(err) {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json({ message: 'Task deleted successfully' });
});
});
// Maintenance Records
app.get('/api/maintenance', (req, res) => {
const sql = 'SELECT * FROM maintenance_records ORDER BY date DESC';
db.all(sql, [], (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(rows.map(row => ({
id: row.id,
plantId: row.plant_id,
date: row.date,
type: row.type,
description: row.description,
amount: row.amount,
isPlanned: Boolean(row.is_planned),
isCompleted: Boolean(row.is_completed),
createdAt: row.created_at,
updatedAt: row.updated_at
})));
});
});
app.post('/api/maintenance', (req, res) => {
const { plantId, date, type, description, amount, isPlanned, isCompleted } = req.body;
const sql = `INSERT INTO maintenance_records (plant_id, date, type, description, amount, is_planned, is_completed)
VALUES (?, ?, ?, ?, ?, ?, ?)`;
db.run(sql, [plantId, date, type, description, amount, isPlanned ? 1 : 0, isCompleted ? 1 : 0], function(err) {
if (err) {
res.status(400).json({ error: err.message });
return;
}
db.get('SELECT * FROM maintenance_records WHERE id = ?', [this.lastID], (err, row) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json({
id: row.id,
plantId: row.plant_id,
date: row.date,
type: row.type,
description: row.description,
amount: row.amount,
isPlanned: Boolean(row.is_planned),
isCompleted: Boolean(row.is_completed),
createdAt: row.created_at,
updatedAt: row.updated_at
});
});
});
});
// Harvest Records
app.get('/api/harvests', (req, res) => {
const sql = 'SELECT * FROM harvest_records ORDER BY date DESC';
db.all(sql, [], (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(rows.map(row => ({
id: row.id,
plantId: row.plant_id,
date: row.date,
quantity: row.quantity,
unit: row.unit,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
})));
});
});
app.post('/api/harvests', (req, res) => {
const { plantId, date, quantity, unit, notes } = req.body;
const sql = `INSERT INTO harvest_records (plant_id, date, quantity, unit, notes)
VALUES (?, ?, ?, ?, ?)`;
db.run(sql, [plantId, date, quantity, unit, notes], function(err) {
if (err) {
res.status(400).json({ error: err.message });
return;
}
db.get('SELECT * FROM harvest_records WHERE id = ?', [this.lastID], (err, row) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json({
id: row.id,
plantId: row.plant_id,
date: row.date,
quantity: row.quantity,
unit: row.unit,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
});
});
});
});
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'OK', message: 'GardenTrack API is running' });
});
// Additional endpoints for enhanced functionality
// Get plant by ID
app.get('/api/plants/:id', (req, res) => {
const sql = 'SELECT * FROM plants WHERE id = ?';
db.get(sql, [req.params.id], (err, row) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
if (!row) {
res.status(404).json({ error: 'Plant not found' });
return;
}
res.json({
id: row.id,
type: row.type,
variety: row.variety,
purchaseLocation: row.purchase_location,
seedlingAge: row.seedling_age,
seedlingHeight: row.seedling_height,
plantingDate: row.planting_date,
healthStatus: row.health_status,
currentHeight: row.current_height,
photoUrl: row.photo_url,
currentPhotoUrl: row.current_photo_url,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
});
});
});
// Search plants
app.get('/api/plants/search', (req, res) => {
const query = req.query.q;
if (!query) {
res.status(400).json({ error: 'Search query is required' });
return;
}
const sql = `SELECT * FROM plants WHERE variety LIKE ? OR type LIKE ? OR notes LIKE ? ORDER BY created_at DESC`;
const searchTerm = `%${query}%`;
db.all(sql, [searchTerm, searchTerm, searchTerm], (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(rows.map(row => ({
id: row.id,
type: row.type,
variety: row.variety,
purchaseLocation: row.purchase_location,
seedlingAge: row.seedling_age,
seedlingHeight: row.seedling_height,
plantingDate: row.planting_date,
healthStatus: row.health_status,
currentHeight: row.current_height,
photoUrl: row.photo_url,
currentPhotoUrl: row.current_photo_url,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
})));
});
});
// Get upcoming tasks
app.get('/api/tasks/upcoming', (req, res) => {
const days = parseInt(req.query.days) || 7;
const endDate = new Date();
endDate.setDate(endDate.getDate() + days);
const sql = 'SELECT * FROM tasks WHERE completed = 0 AND deadline <= ? ORDER BY deadline ASC';
db.all(sql, [endDate.toISOString().split('T')[0]], (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(rows.map(row => ({
id: row.id,
plantId: row.plant_id,
title: row.title,
description: row.description,
deadline: row.deadline,
completed: Boolean(row.completed),
createdAt: row.created_at,
updatedAt: row.updated_at
})));
});
});
// Get overdue tasks
app.get('/api/tasks/overdue', (req, res) => {
const today = new Date().toISOString().split('T')[0];
const sql = 'SELECT * FROM tasks WHERE completed = 0 AND deadline < ? ORDER BY deadline ASC';
db.all(sql, [today], (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(rows.map(row => ({
id: row.id,
plantId: row.plant_id,
title: row.title,
description: row.description,
deadline: row.deadline,
completed: Boolean(row.completed),
createdAt: row.created_at,
updatedAt: row.updated_at
})));
});
});
// Dashboard summary
app.get('/api/dashboard/summary', (req, res) => {
const queries = {
totalPlants: 'SELECT COUNT(*) as count FROM plants',
healthyPlants: 'SELECT COUNT(*) as count FROM plants WHERE health_status = "good"',
plantsNeedingAttention: 'SELECT COUNT(*) as count FROM plants WHERE health_status = "needs-attention"',
totalTasks: 'SELECT COUNT(*) as count FROM tasks',
completedTasks: 'SELECT COUNT(*) as count FROM tasks WHERE completed = 1',
upcomingTasks: 'SELECT COUNT(*) as count FROM tasks WHERE completed = 0 AND deadline >= date("now")',
totalHarvests: 'SELECT COUNT(*) as count FROM harvest_records',
recentMaintenanceCount: 'SELECT COUNT(*) as count FROM maintenance_records WHERE date >= date("now", "-30 days")'
};
const results = {};
const queryKeys = Object.keys(queries);
let completed = 0;
queryKeys.forEach(key => {
db.get(queries[key], [], (err, row) => {
if (err) {
console.error(`Error in ${key} query:`, err);
results[key] = 0;
} else {
results[key] = row.count;
}
completed++;
if (completed === queryKeys.length) {
res.json(results);
}
});
});
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong!' });
});
// Start server
app.listen(PORT, () => {
console.log(`GardenTrack API server running on port ${PORT}`);
});
// Graceful shutdown
process.on('SIGINT', () => {
db.close((err) => {
if (err) {
console.error(err.message);
}
console.log('Database connection closed.');
process.exit(0);
});
});

44
docker-compose.yml Normal file
View File

@ -0,0 +1,44 @@
version: '3.8'
services:
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
ports:
- "3000:3000"
environment:
- VITE_API_URL=/api
depends_on:
- backend
volumes:
- ./src:/app/src
- ./public:/app/public
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- PORT=3001
volumes:
- ./backend/database:/app/database
- ./backend:/app
command: ["node", "server.js"]
db-init:
build:
context: ./backend
dockerfile: Dockerfile
volumes:
- ./backend/database:/app/database
- ./backend:/app
command: ["node", "scripts/init-db.js"]
depends_on:
- backend
volumes:
db_data:

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>GardenTrack - Full-Stack Garden Management System</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

22
nginx.conf Normal file
View File

@ -0,0 +1,22 @@
server {
listen 3000;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3001;
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;
}
}

4561
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "vite-react-typescript-starter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --mode development",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.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"
}
}

6
postcss.config.js Normal file
View File

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

160
src/App.tsx Normal file
View File

@ -0,0 +1,160 @@
import { useState, useEffect } from 'react';
import { Sprout, Calendar, CheckSquare, Activity, Plus } from 'lucide-react';
import { Plant, Task, MaintenanceRecord, HarvestRecord } from './types';
import Dashboard from './components/Dashboard';
import PlantRegistry from './components/PlantRegistry';
import TaskPlanner from './components/TaskPlanner';
import MaintenanceLog from './components/MaintenanceLog';
import { apiService } from './services/api';
function App() {
const [activeTab, setActiveTab] = useState('dashboard');
const [plants, setPlants] = useState<Plant[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [maintenanceRecords, setMaintenanceRecords] = useState<MaintenanceRecord[]>([]);
const [harvestRecords, setHarvestRecords] = useState<HarvestRecord[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
// Test backend connection first
await apiService.healthCheck();
const [plantsData, tasksData, maintenanceData, harvestData] = await Promise.all([
apiService.getPlants(),
apiService.getTasks(),
apiService.getMaintenanceRecords(),
apiService.getHarvestRecords()
]);
setPlants(plantsData);
setTasks(tasksData);
setMaintenanceRecords(maintenanceData);
setHarvestRecords(harvestData);
} catch (error) {
console.error('Error loading data:', error);
// You could add a toast notification or error state here
alert(`Failed to load data: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setLoading(false);
}
};
const navItems = [
{ id: 'dashboard', label: 'Dashboard', icon: Activity },
{ id: 'plants', label: 'Plant Registry', icon: Sprout },
{ id: 'tasks', label: 'Task Planner', icon: CheckSquare },
{ id: 'maintenance', label: 'Maintenance Log', icon: Calendar },
];
if (loading) {
return (
<div className="min-h-screen bg-green-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mx-auto mb-4"></div>
<p className="text-green-700">Loading GardenTrack...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-green-50">
<header className="bg-white shadow-sm border-b border-green-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center space-x-3">
<Sprout className="h-8 w-8 text-green-600" />
<h1 className="text-2xl font-bold text-green-800">GardenTrack</h1>
</div>
<nav className="hidden md:flex space-x-6">
{navItems.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === item.id
? 'bg-green-100 text-green-700'
: 'text-gray-600 hover:text-green-600 hover:bg-green-50'
}`}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</button>
);
})}
</nav>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{activeTab === 'dashboard' && (
<Dashboard
plants={plants}
tasks={tasks}
maintenanceRecords={maintenanceRecords}
harvestRecords={harvestRecords}
onNavigate={setActiveTab}
/>
)}
{activeTab === 'plants' && (
<PlantRegistry
plants={plants}
onPlantsChange={setPlants}
harvestRecords={harvestRecords}
onHarvestRecordsChange={setHarvestRecords}
maintenanceRecords={maintenanceRecords}
/>
)}
{activeTab === 'tasks' && (
<TaskPlanner
tasks={tasks}
plants={plants}
onTasksChange={setTasks}
/>
)}
{activeTab === 'maintenance' && (
<MaintenanceLog
maintenanceRecords={maintenanceRecords}
plants={plants}
onMaintenanceChange={setMaintenanceRecords}
/>
)}
</main>
{/* Mobile Navigation */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-green-100 px-4 py-2">
<div className="flex justify-around">
{navItems.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
className={`flex flex-col items-center py-2 px-3 rounded-md transition-colors ${
activeTab === item.id
? 'text-green-600'
: 'text-gray-400 hover:text-green-600'
}`}
>
<Icon className="h-5 w-5" />
<span className="text-xs mt-1">{item.label.split(' ')[0]}</span>
</button>
);
})}
</div>
</nav>
</div>
);
}
export default App;

View File

@ -0,0 +1,220 @@
import React from 'react';
import { Plant, Task, MaintenanceRecord, HarvestRecord } from '../types';
import { Calendar, AlertTriangle, CheckCircle, Sprout, Scissors, Droplets } from 'lucide-react';
interface DashboardProps {
plants: Plant[];
tasks: Task[];
maintenanceRecords: MaintenanceRecord[];
harvestRecords: HarvestRecord[];
onNavigate: (tab: string) => void;
}
const Dashboard: React.FC<DashboardProps> = ({
plants,
tasks,
maintenanceRecords,
harvestRecords,
onNavigate
}) => {
const upcomingTasks = tasks
.filter(task => !task.completed && new Date(task.deadline) >= new Date())
.sort((a, b) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime())
.slice(0, 5);
const plantsNeedingAttention = plants.filter(plant => plant.healthStatus === 'needs-attention');
const recentMaintenanceRecords = maintenanceRecords
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 5);
const stats = [
{
label: 'Total Plants',
value: plants.length,
icon: Sprout,
color: 'text-green-600',
bgColor: 'bg-green-100'
},
{
label: 'Pending Tasks',
value: tasks.filter(task => !task.completed).length,
icon: Calendar,
color: 'text-blue-600',
bgColor: 'bg-blue-100'
},
{
label: 'Need Attention',
value: plantsNeedingAttention.length,
icon: AlertTriangle,
color: 'text-yellow-600',
bgColor: 'bg-yellow-100'
},
{
label: 'This Year Harvests',
value: harvestRecords.filter(record =>
new Date(record.date).getFullYear() === new Date().getFullYear()
).length,
icon: CheckCircle,
color: 'text-purple-600',
bgColor: 'bg-purple-100'
}
];
const getMaintenanceIcon = (type: string) => {
switch (type) {
case 'pruning': return Scissors;
case 'watering': return Droplets;
default: return Calendar;
}
};
return (
<div className="space-y-8">
<div>
<h2 className="text-3xl font-bold text-green-800 mb-2">Garden Dashboard</h2>
<p className="text-green-600">Welcome to your garden management center</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map((stat, index) => {
const Icon = stat.icon;
return (
<div key={index} className="bg-white rounded-lg shadow-sm border border-green-100 p-6 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{stat.label}</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{stat.value}</p>
</div>
<div className={`${stat.bgColor} p-3 rounded-lg`}>
<Icon className={`h-6 w-6 ${stat.color}`} />
</div>
</div>
</div>
);
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Upcoming Tasks */}
<div className="bg-white rounded-lg shadow-sm border border-green-100">
<div className="p-6 border-b border-green-100">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-green-800">Upcoming Tasks</h3>
<button
onClick={() => onNavigate('tasks')}
className="text-sm text-green-600 hover:text-green-700 transition-colors"
>
View all
</button>
</div>
</div>
<div className="p-6">
{upcomingTasks.length > 0 ? (
<div className="space-y-4">
{upcomingTasks.map((task) => {
const plant = plants.find(p => p.id === task.plantId);
const isOverdue = new Date(task.deadline) < new Date();
return (
<div key={task.id} className="flex items-start space-x-3 p-3 bg-green-50 rounded-lg">
<Calendar className={`h-5 w-5 mt-0.5 ${isOverdue ? 'text-red-500' : 'text-green-600'}`} />
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900">{task.title}</p>
{plant && (
<p className="text-sm text-gray-600">{plant.variety}</p>
)}
<p className={`text-sm ${isOverdue ? 'text-red-600' : 'text-gray-500'}`}>
Due: {new Date(task.deadline).toLocaleDateString()}
</p>
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-center py-8">No upcoming tasks</p>
)}
</div>
</div>
{/* Plants Needing Attention */}
<div className="bg-white rounded-lg shadow-sm border border-green-100">
<div className="p-6 border-b border-green-100">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-green-800">Plants Needing Attention</h3>
<button
onClick={() => onNavigate('plants')}
className="text-sm text-green-600 hover:text-green-700 transition-colors"
>
View all
</button>
</div>
</div>
<div className="p-6">
{plantsNeedingAttention.length > 0 ? (
<div className="space-y-4">
{plantsNeedingAttention.map((plant) => (
<div key={plant.id} className="flex items-start space-x-3 p-3 bg-yellow-50 rounded-lg">
<AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900">{plant.variety}</p>
<p className="text-sm text-gray-600 capitalize">{plant.type}</p>
{plant.notes && (
<p className="text-sm text-gray-500 mt-1">{plant.notes}</p>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-center py-8">All plants are healthy!</p>
)}
</div>
</div>
</div>
{/* Recent Maintenance */}
<div className="bg-white rounded-lg shadow-sm border border-green-100">
<div className="p-6 border-b border-green-100">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-green-800">Recent Maintenance</h3>
<button
onClick={() => onNavigate('maintenance')}
className="text-sm text-green-600 hover:text-green-700 transition-colors"
>
View all
</button>
</div>
</div>
<div className="p-6">
{recentMaintenanceRecords.length > 0 ? (
<div className="space-y-4">
{recentMaintenanceRecords.map((record) => {
const plant = plants.find(p => p.id === record.plantId);
const Icon = getMaintenanceIcon(record.type);
return (
<div key={record.id} className="flex items-start space-x-3 p-3 bg-green-50 rounded-lg">
<Icon className="h-5 w-5 text-green-600 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 capitalize">{record.type}</p>
<p className="text-sm text-gray-600">{plant?.variety}</p>
<p className="text-sm text-gray-500">{record.description}</p>
<p className="text-xs text-gray-400 mt-1">
{new Date(record.date).toLocaleDateString()}
</p>
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-center py-8">No maintenance records yet</p>
)}
</div>
</div>
</div>
);
};
export default Dashboard;

View File

@ -0,0 +1,140 @@
import React, { useState } from 'react';
import { Plant, HarvestRecord } from '../types';
import { X } from 'lucide-react';
interface HarvestFormProps {
plant: Plant;
onSave: (harvest: Omit<HarvestRecord, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void;
}
const HarvestForm: React.FC<HarvestFormProps> = ({ plant, onSave, onCancel }) => {
const [formData, setFormData] = useState({
plantId: plant.id,
date: new Date().toISOString().split('T')[0],
quantity: '',
unit: 'lbs',
notes: ''
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
...formData,
quantity: parseFloat(formData.quantity)
});
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full">
<div className="flex justify-between items-center p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
Record Harvest: {plant.variety}
</h3>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label htmlFor="date" className="block text-sm font-medium text-gray-700 mb-1">
Harvest Date *
</label>
<input
type="date"
id="date"
name="date"
value={formData.date}
onChange={handleChange}
required
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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="quantity" className="block text-sm font-medium text-gray-700 mb-1">
Quantity *
</label>
<input
type="number"
id="quantity"
name="quantity"
value={formData.quantity}
onChange={handleChange}
step="0.1"
min="0"
required
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"
/>
</div>
<div>
<label htmlFor="unit" className="block text-sm font-medium text-gray-700 mb-1">
Unit
</label>
<select
id="unit"
name="unit"
value={formData.unit}
onChange={handleChange}
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"
>
<option value="lbs">Pounds (lbs)</option>
<option value="kg">Kilograms (kg)</option>
<option value="pieces">Pieces</option>
<option value="bunches">Bunches</option>
<option value="cups">Cups</option>
<option value="liters">Liters</option>
<option value="gallons">Gallons</option>
</select>
</div>
</div>
<div>
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">
Notes
</label>
<textarea
id="notes"
name="notes"
value={formData.notes}
onChange={handleChange}
placeholder="Quality, taste, storage notes..."
rows={3}
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 resize-none"
/>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors"
>
Record Harvest
</button>
</div>
</form>
</div>
</div>
);
};
export default HarvestForm;

View File

@ -0,0 +1,194 @@
import React, { useState } from 'react';
import { Plant, MaintenanceRecord } from '../types';
import { X } from 'lucide-react';
interface MaintenanceFormProps {
plants: Plant[];
onSave: (maintenance: Omit<MaintenanceRecord, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void;
}
const MaintenanceForm: React.FC<MaintenanceFormProps> = ({ plants, onSave, onCancel }) => {
const [formData, setFormData] = useState({
plantId: '',
date: new Date().toISOString().split('T')[0],
type: 'other' as MaintenanceRecord['type'],
description: '',
amount: '',
isPlanned: false,
isCompleted: true
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
...formData,
plantId: parseInt(formData.plantId),
amount: formData.amount || undefined,
isPlanned: formData.isPlanned,
isCompleted: formData.isCompleted
});
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value
}));
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">Log Maintenance Activity</h3>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label htmlFor="plantId" className="block text-sm font-medium text-gray-700 mb-1">
Plant *
</label>
<select
id="plantId"
name="plantId"
value={formData.plantId}
onChange={handleChange}
required
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"
>
<option value="">Select a plant</option>
{plants.map(plant => (
<option key={plant.id} value={plant.id}>
{plant.variety} ({plant.type})
</option>
))}
</select>
</div>
<div>
<label htmlFor="date" className="block text-sm font-medium text-gray-700 mb-1">
Date *
</label>
<input
type="date"
id="date"
name="date"
value={formData.date}
onChange={handleChange}
required
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"
/>
</div>
<div>
<label htmlFor="type" className="block text-sm font-medium text-gray-700 mb-1">
Activity Type *
</label>
<select
id="type"
name="type"
value={formData.type}
onChange={handleChange}
required
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"
>
<option value="other">Other</option>
<option value="chemical">Chemical Treatment</option>
<option value="fertilizer">Fertilizer Application</option>
<option value="watering">Watering</option>
<option value="pruning">Pruning</option>
<option value="transplanting">Transplanting</option>
</select>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description *
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
placeholder="Describe the maintenance activity..."
required
rows={4}
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 resize-none"
/>
</div>
<div>
<label htmlFor="amount" className="block text-sm font-medium text-gray-700 mb-1">
Amount/Quantity (Optional)
</label>
<input
type="text"
id="amount"
name="amount"
value={formData.amount}
onChange={handleChange}
placeholder="e.g., 2 cups, 1 gallon, 500ml"
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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center">
<input
type="checkbox"
id="isPlanned"
name="isPlanned"
checked={formData.isPlanned}
onChange={handleChange}
className="h-4 w-4 text-green-600 focus:ring-green-500 border-gray-300 rounded"
/>
<label htmlFor="isPlanned" className="ml-2 block text-sm text-gray-700">
Planned Activity
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="isCompleted"
name="isCompleted"
checked={formData.isCompleted}
onChange={handleChange}
className="h-4 w-4 text-green-600 focus:ring-green-500 border-gray-300 rounded"
/>
<label htmlFor="isCompleted" className="ml-2 block text-sm text-gray-700">
Completed
</label>
</div>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors"
>
Log Activity
</button>
</div>
</form>
</div>
</div>
);
};
export default MaintenanceForm;

View File

@ -0,0 +1,203 @@
import React, { useState } from 'react';
import { Plant, MaintenanceRecord } from '../types';
import { Plus, Calendar, Search, Scissors, Droplets, Beaker, Sprout } from 'lucide-react';
import MaintenanceForm from './MaintenanceForm';
import { apiService } from '../services/api';
interface MaintenanceLogProps {
maintenanceRecords: MaintenanceRecord[];
plants: Plant[];
onMaintenanceChange: (records: MaintenanceRecord[]) => void;
}
const MaintenanceLog: React.FC<MaintenanceLogProps> = ({
maintenanceRecords,
plants,
onMaintenanceChange
}) => {
const [showMaintenanceForm, setShowMaintenanceForm] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState('all');
const [filterPlant, setFilterPlant] = useState('all');
const filteredRecords = maintenanceRecords.filter(record => {
const plant = plants.find(p => p.id === record.plantId);
const matchesSearch = record.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
(plant?.variety.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesType = filterType === 'all' || record.type === filterType;
const matchesPlant = filterPlant === 'all' || record.plantId.toString() === filterPlant;
return matchesSearch && matchesType && matchesPlant;
}).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const handleSaveMaintenance = async (maintenanceData: Omit<MaintenanceRecord, 'id' | 'createdAt' | 'updatedAt'>) => {
try {
const newRecord = await apiService.createMaintenanceRecord(maintenanceData);
onMaintenanceChange([...maintenanceRecords, newRecord]);
setShowMaintenanceForm(false);
} catch (error) {
console.error('Error saving maintenance record:', error);
}
};
const getMaintenanceIcon = (type: string) => {
switch (type) {
case 'chemical': return Beaker;
case 'fertilizer': return Sprout;
case 'watering': return Droplets;
case 'pruning': return Scissors;
case 'transplanting': return Sprout;
default: return Calendar;
}
};
const getTypeColor = (type: string) => {
switch (type) {
case 'chemical': return 'bg-red-100 text-red-800';
case 'fertilizer': return 'bg-green-100 text-green-800';
case 'watering': return 'bg-blue-100 text-blue-800';
case 'pruning': return 'bg-yellow-100 text-yellow-800';
case 'transplanting': return 'bg-purple-100 text-purple-800';
default: return 'bg-gray-100 text-gray-800';
}
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold text-green-800">Maintenance Log</h2>
<p className="text-green-600 mt-1">Track all your garden maintenance activities</p>
</div>
<button
onClick={() => setShowMaintenanceForm(true)}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center space-x-2 transition-colors"
>
<Plus className="h-4 w-4" />
<span>Log Activity</span>
</button>
</div>
{/* Filters */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-green-100">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Search</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Search maintenance records..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Activity Type</label>
<select
value={filterType}
onChange={(e) => setFilterType(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"
>
<option value="all">All Types</option>
<option value="chemical">Chemical Treatment</option>
<option value="fertilizer">Fertilizer</option>
<option value="watering">Watering</option>
<option value="pruning">Pruning</option>
<option value="transplanting">Transplanting</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Plant</label>
<select
value={filterPlant}
onChange={(e) => setFilterPlant(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"
>
<option value="all">All Plants</option>
{plants.map(plant => (
<option key={plant.id} value={plant.id}>
{plant.variety}
</option>
))}
</select>
</div>
</div>
</div>
{/* Maintenance Records */}
<div className="space-y-4">
{filteredRecords.map((record) => {
const plant = plants.find(p => p.id === record.plantId);
const Icon = getMaintenanceIcon(record.type);
return (
<div key={record.id} className="bg-white rounded-lg shadow-sm border border-green-100 p-6 hover:shadow-md transition-shadow">
<div className="flex items-start space-x-4">
<div className="bg-green-100 p-3 rounded-lg">
<Icon className="h-6 w-6 text-green-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-3">
<h3 className="text-lg font-semibold text-gray-900 capitalize">{record.type}</h3>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(record.type)}`}>
{record.type.replace('-', ' ')}
</span>
</div>
<span className="text-sm text-gray-500">
{new Date(record.date).toLocaleDateString()}
</span>
</div>
{plant && (
<p className="text-sm text-gray-600 mb-2">
Plant: <span className="font-medium">{plant.variety}</span>
</p>
)}
<p className="text-gray-700 mb-2">{record.description}</p>
{record.amount && (
<p className="text-sm text-gray-600">
Amount: <span className="font-medium">{record.amount}</span>
</p>
)}
</div>
</div>
</div>
);
})}
</div>
{filteredRecords.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">No maintenance records found.</p>
<button
onClick={() => setShowMaintenanceForm(true)}
className="mt-4 text-green-600 hover:text-green-700 transition-colors"
>
Log your first activity
</button>
</div>
)}
{/* Maintenance Form Modal */}
{showMaintenanceForm && (
<MaintenanceForm
plants={plants}
onSave={handleSaveMaintenance}
onCancel={() => setShowMaintenanceForm(false)}
/>
)}
</div>
);
};
export default MaintenanceLog;

View File

@ -0,0 +1,261 @@
import React, { useState, useEffect } from 'react';
import { Plant } from '../types';
import { X } from 'lucide-react';
interface PlantFormProps {
plant?: Plant | null;
onSave: (plant: Omit<Plant, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void;
}
const PlantForm: React.FC<PlantFormProps> = ({ plant, onSave, onCancel }) => {
const [formData, setFormData] = useState({
type: '',
variety: '',
purchaseLocation: '',
seedlingAge: '',
seedlingHeight: '',
plantingDate: '',
healthStatus: 'good' as Plant['healthStatus'],
currentHeight: '',
photoUrl: '',
notes: ''
});
useEffect(() => {
if (plant) {
setFormData({
type: plant.type,
variety: plant.variety,
purchaseLocation: plant.purchaseLocation,
seedlingAge: plant.seedlingAge.toString(),
seedlingHeight: plant.seedlingHeight.toString(),
plantingDate: plant.plantingDate,
healthStatus: plant.healthStatus,
currentHeight: plant.currentHeight?.toString() || '',
photoUrl: plant.photoUrl || '',
notes: plant.notes
});
}
}, [plant]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
...formData,
seedlingAge: parseInt(formData.seedlingAge),
seedlingHeight: parseFloat(formData.seedlingHeight),
currentHeight: formData.currentHeight ? parseFloat(formData.currentHeight) : undefined,
photoUrl: formData.photoUrl || undefined
});
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
{plant ? 'Edit Plant' : 'Add New Plant'}
</h3>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label htmlFor="type" className="block text-sm font-medium text-gray-700 mb-1">
Plant Type *
</label>
<select
id="type"
name="type"
value={formData.type}
onChange={handleChange}
required
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"
>
<option value="">Select a type</option>
<option value="tree">Tree</option>
<option value="shrub">Shrub</option>
<option value="herb">Herb</option>
<option value="vegetable">Vegetable</option>
<option value="flower">Flower</option>
<option value="vine">Vine</option>
<option value="grass">Grass</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label htmlFor="variety" className="block text-sm font-medium text-gray-700 mb-1">
Variety/Species *
</label>
<input
type="text"
id="variety"
name="variety"
value={formData.variety}
onChange={handleChange}
placeholder="e.g., Apple - Honeycrisp"
required
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"
/>
</div>
<div>
<label htmlFor="purchaseLocation" className="block text-sm font-medium text-gray-700 mb-1">
Purchase Location *
</label>
<input
type="text"
id="purchaseLocation"
name="purchaseLocation"
value={formData.purchaseLocation}
onChange={handleChange}
placeholder="e.g., Local Nursery, Garden Center"
required
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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="seedlingAge" className="block text-sm font-medium text-gray-700 mb-1">
Seedling Age (months) *
</label>
<input
type="number"
id="seedlingAge"
name="seedlingAge"
value={formData.seedlingAge}
onChange={handleChange}
min="0"
required
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"
/>
</div>
<div>
<label htmlFor="seedlingHeight" className="block text-sm font-medium text-gray-700 mb-1">
Seedling Height (cm) *
</label>
<input
type="number"
id="seedlingHeight"
name="seedlingHeight"
value={formData.seedlingHeight}
onChange={handleChange}
step="0.1"
min="0"
required
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"
/>
</div>
</div>
<div>
<label htmlFor="plantingDate" className="block text-sm font-medium text-gray-700 mb-1">
Planting Date *
</label>
<input
type="date"
id="plantingDate"
name="plantingDate"
value={formData.plantingDate}
onChange={handleChange}
required
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"
/>
</div>
<div>
<label htmlFor="currentHeight" className="block text-sm font-medium text-gray-700 mb-1">
Current Height (cm)
</label>
<input
type="number"
id="currentHeight"
name="currentHeight"
value={formData.currentHeight}
onChange={handleChange}
step="0.1"
min="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"
/>
</div>
<div>
<label htmlFor="healthStatus" className="block text-sm font-medium text-gray-700 mb-1">
Health Status
</label>
<select
id="healthStatus"
name="healthStatus"
value={formData.healthStatus}
onChange={handleChange}
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"
>
<option value="good">Good</option>
<option value="needs-attention">Needs Attention</option>
<option value="dead">Dead</option>
</select>
</div>
<div>
<label htmlFor="photoUrl" className="block text-sm font-medium text-gray-700 mb-1">
Photo URL
</label>
<input
type="url"
id="photoUrl"
name="photoUrl"
value={formData.photoUrl}
onChange={handleChange}
placeholder="https://example.com/plant-photo.jpg"
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"
/>
</div>
<div>
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">
Special Notes
</label>
<textarea
id="notes"
name="notes"
value={formData.notes}
onChange={handleChange}
placeholder="Special notes about this plant..."
rows={3}
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 resize-none"
/>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors"
>
{plant ? 'Update Plant' : 'Add Plant'}
</button>
</div>
</form>
</div>
</div>
);
};
export default PlantForm;

View File

@ -0,0 +1,325 @@
import React, { useState } from 'react';
import { Plant, HarvestRecord } from '../types';
import { Plus, Search, Edit, Trash2, Calendar, Award, BarChart3 } from 'lucide-react';
import PlantForm from './PlantForm';
import HarvestForm from './HarvestForm';
import PlantStatistics from './PlantStatistics';
import { apiService } from '../services/api';
interface PlantRegistryProps {
plants: Plant[];
onPlantsChange: (plants: Plant[]) => void;
harvestRecords: HarvestRecord[];
onHarvestRecordsChange: (records: HarvestRecord[]) => void;
maintenanceRecords: any[];
}
const PlantRegistry: React.FC<PlantRegistryProps> = ({
plants,
onPlantsChange,
harvestRecords,
onHarvestRecordsChange,
maintenanceRecords
}) => {
const [showPlantForm, setShowPlantForm] = useState(false);
const [showHarvestForm, setShowHarvestForm] = useState(false);
const [showStatistics, setShowStatistics] = useState(false);
const [editingPlant, setEditingPlant] = useState<Plant | null>(null);
const [selectedPlantForHarvest, setSelectedPlantForHarvest] = useState<Plant | null>(null);
const [selectedPlantForStats, setSelectedPlantForStats] = useState<Plant | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const filteredPlants = plants.filter(plant => {
const matchesSearch = plant.variety.toLowerCase().includes(searchTerm.toLowerCase()) ||
plant.type.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = filterType === 'all' || plant.type === filterType;
const matchesStatus = filterStatus === 'all' || plant.healthStatus === filterStatus;
return matchesSearch && matchesType && matchesStatus;
});
const plantTypes = [...new Set(plants.map(plant => plant.type))];
const handleSavePlant = async (plantData: Omit<Plant, 'id' | 'createdAt' | 'updatedAt'>) => {
try {
if (editingPlant) {
const updatedPlant = await apiService.updatePlant(editingPlant.id, plantData);
onPlantsChange(plants.map(p => p.id === editingPlant.id ? updatedPlant : p));
} else {
const newPlant = await apiService.createPlant(plantData);
onPlantsChange([...plants, newPlant]);
}
setShowPlantForm(false);
setEditingPlant(null);
} catch (error) {
console.error('Error saving plant:', error);
}
};
const handleDeletePlant = async (plantId: number) => {
if (window.confirm('Are you sure you want to delete this plant?')) {
try {
await apiService.deletePlant(plantId);
onPlantsChange(plants.filter(p => p.id !== plantId));
} catch (error) {
console.error('Error deleting plant:', error);
}
}
};
const handleSaveHarvest = async (harvestData: Omit<HarvestRecord, 'id' | 'createdAt' | 'updatedAt'>) => {
try {
const newHarvest = await apiService.createHarvestRecord(harvestData);
onHarvestRecordsChange([...harvestRecords, newHarvest]);
setShowHarvestForm(false);
setSelectedPlantForHarvest(null);
} catch (error) {
console.error('Error saving harvest:', error);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'good': return 'bg-green-100 text-green-800';
case 'needs-attention': return 'bg-yellow-100 text-yellow-800';
case 'dead': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const getPlantHarvests = (plantId: number) => {
return harvestRecords.filter(record => record.plantId === plantId);
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold text-green-800">Plant Registry</h2>
<p className="text-green-600 mt-1">Manage your garden plants and track their progress</p>
</div>
<button
onClick={() => setShowPlantForm(true)}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center space-x-2 transition-colors"
>
<Plus className="h-4 w-4" />
<span>Add Plant</span>
</button>
</div>
{/* Filters */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-green-100">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Search</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Search by variety or type..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Plant Type</label>
<select
value={filterType}
onChange={(e) => setFilterType(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"
>
<option value="all">All Types</option>
{plantTypes.map(type => (
<option key={type} value={type} className="capitalize">{type}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Health Status</label>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(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"
>
<option value="all">All Status</option>
<option value="good">Good</option>
<option value="needs-attention">Needs Attention</option>
<option value="dead">Dead</option>
</select>
</div>
</div>
</div>
{/* Plants Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredPlants.map((plant) => {
const plantHarvests = getPlantHarvests(plant.id);
const lastHarvest = plantHarvests.sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
)[0];
return (
<div key={plant.id} className="bg-white rounded-lg shadow-sm border border-green-100 hover:shadow-md transition-shadow">
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">{plant.variety}</h3>
<p className="text-sm text-gray-600 capitalize">{plant.type}</p>
<p className="text-xs text-gray-500">From: {plant.purchaseLocation}</p>
</div>
<div className="flex space-x-2">
<button
onClick={() => {
setSelectedPlantForStats(plant);
setShowStatistics(true);
}}
className="p-1 text-purple-600 hover:text-purple-700 transition-colors"
title="View Statistics"
>
<BarChart3 className="h-4 w-4" />
</button>
<button
onClick={() => {
setSelectedPlantForHarvest(plant);
setShowHarvestForm(true);
}}
className="p-1 text-green-600 hover:text-green-700 transition-colors"
title="Add Harvest"
>
<Award className="h-4 w-4" />
</button>
<button
onClick={() => {
setEditingPlant(plant);
setShowPlantForm(true);
}}
className="p-1 text-blue-600 hover:text-blue-700 transition-colors"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeletePlant(plant.id)}
className="p-1 text-red-600 hover:text-red-700 transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="space-y-3">
{plant.photoUrl && (
<div className="mb-3">
<img
src={plant.photoUrl}
alt={plant.variety}
className="w-full h-32 object-cover rounded-lg"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-600">
Planted: {new Date(plant.plantingDate).toLocaleDateString()}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-sm text-gray-600">
<div>Age: {plant.seedlingAge} months</div>
<div>Initial: {plant.seedlingHeight}cm</div>
{plant.currentHeight && (
<>
<div>Current: {plant.currentHeight}cm</div>
<div>Growth: +{(plant.currentHeight - plant.seedlingHeight).toFixed(1)}cm</div>
</>
)}
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Status:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(plant.healthStatus)}`}>
{plant.healthStatus.replace('-', ' ')}
</span>
</div>
{lastHarvest && (
<div className="bg-green-50 p-3 rounded-lg">
<p className="text-sm font-medium text-green-800">Last Harvest:</p>
<p className="text-sm text-green-600">
{lastHarvest.quantity} {lastHarvest.unit} on {new Date(lastHarvest.date).toLocaleDateString()}
</p>
</div>
)}
{plant.notes && (
<div className="border-t pt-3">
<p className="text-sm text-gray-600">{plant.notes}</p>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
{filteredPlants.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">No plants found matching your filters.</p>
<button
onClick={() => setShowPlantForm(true)}
className="mt-4 text-green-600 hover:text-green-700 transition-colors"
>
Add your first plant
</button>
</div>
)}
{/* Plant Form Modal */}
{showPlantForm && (
<PlantForm
plant={editingPlant}
onSave={handleSavePlant}
onCancel={() => {
setShowPlantForm(false);
setEditingPlant(null);
}}
/>
)}
{/* Harvest Form Modal */}
{showHarvestForm && selectedPlantForHarvest && (
<HarvestForm
plant={selectedPlantForHarvest}
onSave={handleSaveHarvest}
onCancel={() => {
setShowHarvestForm(false);
setSelectedPlantForHarvest(null);
}}
/>
)}
{/* Plant Statistics Modal */}
{showStatistics && selectedPlantForStats && (
<PlantStatistics
plant={selectedPlantForStats}
harvestRecords={harvestRecords}
maintenanceRecords={maintenanceRecords}
onClose={() => {
setShowStatistics(false);
setSelectedPlantForStats(null);
}}
/>
)}
</div>
);
};
export default PlantRegistry;

View File

@ -0,0 +1,208 @@
import React, { useState, useEffect } from 'react';
import { Plant, PlantStatistics, HarvestRecord, MaintenanceRecord } from '../types';
import { BarChart3, TrendingUp, Calendar, Award } from 'lucide-react';
interface PlantStatisticsProps {
plant: Plant;
harvestRecords: HarvestRecord[];
maintenanceRecords: MaintenanceRecord[];
onClose: () => void;
}
const PlantStatisticsComponent: React.FC<PlantStatisticsProps> = ({
plant,
harvestRecords,
maintenanceRecords,
onClose
}) => {
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
const [statistics, setStatistics] = useState<PlantStatistics | null>(null);
const availableYears = Array.from(
new Set([
new Date(plant.plantingDate).getFullYear(),
...harvestRecords.map(h => new Date(h.date).getFullYear()),
...maintenanceRecords.map(m => new Date(m.date).getFullYear()),
new Date().getFullYear()
])
).sort((a, b) => b - a);
useEffect(() => {
calculateStatistics();
}, [selectedYear, plant, harvestRecords, maintenanceRecords]);
const calculateStatistics = () => {
const yearHarvests = harvestRecords.filter(h =>
h.plantId === plant.id && new Date(h.date).getFullYear() === selectedYear
);
const yearMaintenance = maintenanceRecords.filter(m =>
m.plantId === plant.id && new Date(m.date).getFullYear() === selectedYear
);
const totalHarvest = yearHarvests.reduce((sum, h) => sum + h.quantity, 0);
const maintenanceCount = {
chemical: yearMaintenance.filter(m => m.type === 'chemical').length,
fertilizer: yearMaintenance.filter(m => m.type === 'fertilizer').length,
watering: yearMaintenance.filter(m => m.type === 'watering').length,
pruning: yearMaintenance.filter(m => m.type === 'pruning').length,
};
const heightGrowth = plant.currentHeight && plant.seedlingHeight
? plant.currentHeight - plant.seedlingHeight
: 0;
setStatistics({
plantId: plant.id,
year: selectedYear,
bloomingDates: [], // Would come from plant history
fruitingDates: [], // Would come from plant history
harvestDates: yearHarvests.map(h => h.date),
totalHarvest,
heightGrowth,
maintenanceCount,
completedTasks: yearMaintenance.filter(m => m.isCompleted).length,
plannedTasks: yearMaintenance.filter(m => m.isPlanned).length
});
};
if (!statistics) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center p-6 border-b border-gray-200">
<div>
<h3 className="text-xl font-semibold text-gray-900">
{plant.variety} - Statistics
</h3>
<p className="text-sm text-gray-600">Year: {selectedYear}</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
×
</button>
</div>
<div className="p-6">
{/* Year Selector */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Year
</label>
<select
value={selectedYear}
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
>
{availableYears.map(year => (
<option key={year} value={year}>{year}</option>
))}
</select>
</div>
{/* Statistics Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-green-50 p-4 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-green-600">Total Harvest</p>
<p className="text-2xl font-bold text-green-800">
{statistics.totalHarvest.toFixed(1)}
</p>
</div>
<Award className="h-8 w-8 text-green-600" />
</div>
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-blue-600">Height Growth</p>
<p className="text-2xl font-bold text-blue-800">
+{statistics.heightGrowth.toFixed(1)}cm
</p>
</div>
<TrendingUp className="h-8 w-8 text-blue-600" />
</div>
</div>
<div className="bg-purple-50 p-4 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-purple-600">Harvest Events</p>
<p className="text-2xl font-bold text-purple-800">
{statistics.harvestDates.length}
</p>
</div>
<Calendar className="h-8 w-8 text-purple-600" />
</div>
</div>
<div className="bg-orange-50 p-4 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-orange-600">Maintenance</p>
<p className="text-2xl font-bold text-orange-800">
{Object.values(statistics.maintenanceCount).reduce((a, b) => a + b, 0)}
</p>
</div>
<BarChart3 className="h-8 w-8 text-orange-600" />
</div>
</div>
</div>
{/* Maintenance Breakdown */}
<div className="bg-gray-50 p-6 rounded-lg mb-6">
<h4 className="text-lg font-semibold text-gray-900 mb-4">Maintenance Activities</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<p className="text-2xl font-bold text-red-600">{statistics.maintenanceCount.chemical}</p>
<p className="text-sm text-gray-600">Chemical Treatments</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-green-600">{statistics.maintenanceCount.fertilizer}</p>
<p className="text-sm text-gray-600">Fertilizations</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">{statistics.maintenanceCount.watering}</p>
<p className="text-sm text-gray-600">Watering</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-yellow-600">{statistics.maintenanceCount.pruning}</p>
<p className="text-sm text-gray-600">Pruning</p>
</div>
</div>
</div>
{/* Harvest Timeline */}
{statistics.harvestDates.length > 0 && (
<div className="bg-green-50 p-6 rounded-lg">
<h4 className="text-lg font-semibold text-gray-900 mb-4">Harvest Timeline</h4>
<div className="space-y-2">
{statistics.harvestDates.map((date, index) => {
const harvest = harvestRecords.find(h => h.date === date && h.plantId === plant.id);
return (
<div key={index} className="flex justify-between items-center py-2 px-3 bg-white rounded">
<span className="text-sm text-gray-600">
{new Date(date).toLocaleDateString()}
</span>
<span className="text-sm font-medium text-green-600">
{harvest?.quantity} {harvest?.unit}
</span>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default PlantStatisticsComponent;

168
src/components/TaskForm.tsx Normal file
View File

@ -0,0 +1,168 @@
import React, { useState, useEffect } from 'react';
import { Plant, Task } from '../types';
import { X } from 'lucide-react';
interface TaskFormProps {
task?: Task | null;
plants: Plant[];
onSave: (task: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void;
}
const TaskForm: React.FC<TaskFormProps> = ({ task, plants, onSave, onCancel }) => {
const [formData, setFormData] = useState({
plantId: '',
title: '',
description: '',
deadline: '',
completed: false
});
useEffect(() => {
if (task) {
setFormData({
plantId: task.plantId?.toString() || '',
title: task.title,
description: task.description,
deadline: task.deadline,
completed: task.completed
});
}
}, [task]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
...formData,
plantId: formData.plantId ? parseInt(formData.plantId) : undefined
});
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value
}));
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
{task ? 'Edit Task' : 'Create New Task'}
</h3>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
Task Title *
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleChange}
placeholder="e.g., Apply fertilizer"
required
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"
/>
</div>
<div>
<label htmlFor="plantId" className="block text-sm font-medium text-gray-700 mb-1">
Associated Plant (Optional)
</label>
<select
id="plantId"
name="plantId"
value={formData.plantId}
onChange={handleChange}
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"
>
<option value="">Select a plant (optional)</option>
{plants.map(plant => (
<option key={plant.id} value={plant.id}>
{plant.variety} ({plant.type})
</option>
))}
</select>
</div>
<div>
<label htmlFor="deadline" className="block text-sm font-medium text-gray-700 mb-1">
Deadline *
</label>
<input
type="date"
id="deadline"
name="deadline"
value={formData.deadline}
onChange={handleChange}
required
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"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
placeholder="Detailed description of the task..."
rows={4}
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 resize-none"
/>
</div>
{task && (
<div className="flex items-center">
<input
type="checkbox"
id="completed"
name="completed"
checked={formData.completed}
onChange={handleChange}
className="h-4 w-4 text-green-600 focus:ring-green-500 border-gray-300 rounded"
/>
<label htmlFor="completed" className="ml-2 block text-sm text-gray-700">
Mark as completed
</label>
</div>
)}
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors"
>
{task ? 'Update Task' : 'Create Task'}
</button>
</div>
</form>
</div>
</div>
);
};
export default TaskForm;

View File

@ -0,0 +1,239 @@
import React, { useState } from 'react';
import { Plant, Task } from '../types';
import { Plus, Calendar, CheckCircle, Clock, Edit, Trash2 } from 'lucide-react';
import TaskForm from './TaskForm';
import { apiService } from '../services/api';
interface TaskPlannerProps {
tasks: Task[];
plants: Plant[];
onTasksChange: (tasks: Task[]) => void;
}
const TaskPlanner: React.FC<TaskPlannerProps> = ({ tasks, plants, onTasksChange }) => {
const [showTaskForm, setShowTaskForm] = useState(false);
const [editingTask, setEditingTask] = useState<Task | null>(null);
const [filter, setFilter] = useState<'all' | 'pending' | 'completed'>('all');
const filteredTasks = tasks.filter(task => {
if (filter === 'pending') return !task.completed;
if (filter === 'completed') return task.completed;
return true;
}).sort((a, b) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime());
const handleSaveTask = async (taskData: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>) => {
try {
if (editingTask) {
const updatedTask = await apiService.updateTask(editingTask.id, taskData);
onTasksChange(tasks.map(t => t.id === editingTask.id ? updatedTask : t));
} else {
const newTask = await apiService.createTask(taskData);
onTasksChange([...tasks, newTask]);
}
setShowTaskForm(false);
setEditingTask(null);
} catch (error) {
console.error('Error saving task:', error);
}
};
const handleToggleComplete = async (taskId: number) => {
const task = tasks.find(t => t.id === taskId);
if (task) {
try {
const updatedTask = await apiService.updateTask(taskId, { completed: !task.completed });
onTasksChange(tasks.map(t => t.id === taskId ? updatedTask : t));
} catch (error) {
console.error('Error updating task:', error);
}
}
};
const handleDeleteTask = async (taskId: number) => {
if (window.confirm('Are you sure you want to delete this task?')) {
try {
await apiService.deleteTask(taskId);
onTasksChange(tasks.filter(t => t.id !== taskId));
} catch (error) {
console.error('Error deleting task:', error);
}
}
};
const getTaskStatus = (task: Task) => {
if (task.completed) return 'completed';
if (new Date(task.deadline) < new Date()) return 'overdue';
return 'pending';
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'text-green-600';
case 'overdue': return 'text-red-600';
case 'pending': return 'text-yellow-600';
default: return 'text-gray-600';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed': return CheckCircle;
case 'overdue': return Clock;
case 'pending': return Calendar;
default: return Calendar;
}
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold text-green-800">Task Planner</h2>
<p className="text-green-600 mt-1">Plan and track your garden maintenance tasks</p>
</div>
<button
onClick={() => setShowTaskForm(true)}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center space-x-2 transition-colors"
>
<Plus className="h-4 w-4" />
<span>Add Task</span>
</button>
</div>
{/* Filter Tabs */}
<div className="bg-white rounded-lg shadow-sm border border-green-100">
<div className="flex border-b border-green-100">
{[
{ key: 'all', label: 'All Tasks', count: tasks.length },
{ key: 'pending', label: 'Pending', count: tasks.filter(t => !t.completed).length },
{ key: 'completed', label: 'Completed', count: tasks.filter(t => t.completed).length }
].map((tab) => (
<button
key={tab.key}
onClick={() => setFilter(tab.key as any)}
className={`flex-1 px-6 py-4 text-sm font-medium transition-colors ${
filter === tab.key
? 'text-green-600 border-b-2 border-green-600 bg-green-50'
: 'text-gray-600 hover:text-green-600 hover:bg-green-50'
}`}
>
{tab.label} ({tab.count})
</button>
))}
</div>
</div>
{/* Tasks List */}
<div className="space-y-4">
{filteredTasks.map((task) => {
const plant = plants.find(p => p.id === task.plantId);
const status = getTaskStatus(task);
const StatusIcon = getStatusIcon(status);
return (
<div
key={task.id}
className={`bg-white rounded-lg shadow-sm border border-green-100 p-6 hover:shadow-md transition-shadow ${
task.completed ? 'opacity-75' : ''
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4 flex-1">
<button
onClick={() => handleToggleComplete(task.id)}
className={`mt-1 p-1 rounded-full transition-colors ${
task.completed
? 'text-green-600 hover:text-green-700'
: 'text-gray-400 hover:text-green-600'
}`}
>
<CheckCircle className={`h-5 w-5 ${task.completed ? 'fill-current' : ''}`} />
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-3 mb-2">
<h3 className={`text-lg font-semibold ${task.completed ? 'line-through text-gray-500' : 'text-gray-900'}`}>
{task.title}
</h3>
<div className="flex items-center space-x-1">
<StatusIcon className={`h-4 w-4 ${getStatusColor(status)}`} />
<span className={`text-sm font-medium capitalize ${getStatusColor(status)}`}>
{status}
</span>
</div>
</div>
{plant && (
<p className="text-sm text-gray-600 mb-2">
Plant: <span className="font-medium">{plant.variety}</span>
</p>
)}
<p className="text-gray-700 mb-3">{task.description}</p>
<div className="flex items-center space-x-4 text-sm text-gray-500">
<div className="flex items-center space-x-1">
<Calendar className="h-4 w-4" />
<span>Due: {new Date(task.deadline).toLocaleDateString()}</span>
</div>
{new Date(task.deadline) < new Date() && !task.completed && (
<span className="text-red-600 font-medium">Overdue</span>
)}
</div>
</div>
</div>
<div className="flex space-x-2 ml-4">
<button
onClick={() => {
setEditingTask(task);
setShowTaskForm(true);
}}
className="p-1 text-blue-600 hover:text-blue-700 transition-colors"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteTask(task.id)}
className="p-1 text-red-600 hover:text-red-700 transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
})}
</div>
{filteredTasks.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">
{filter === 'all' ? 'No tasks created yet.' : `No ${filter} tasks.`}
</p>
<button
onClick={() => setShowTaskForm(true)}
className="mt-4 text-green-600 hover:text-green-700 transition-colors"
>
Create your first task
</button>
</div>
)}
{/* Task Form Modal */}
{showTaskForm && (
<TaskForm
task={editingTask}
plants={plants}
onSave={handleSaveTask}
onCancel={() => {
setShowTaskForm(false);
setEditingTask(null);
}}
/>
)}
</div>
);
};
export default TaskPlanner;

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 React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

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

@ -0,0 +1,268 @@
import { Plant, Task, MaintenanceRecord, HarvestRecord, PlantHistory } from '../types';
class ApiService {
private baseUrl: string;
constructor() {
// Use environment variable or fallback to localhost for development
this.baseUrl = import.meta.env.VITE_API_URL || '/api';
}
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorText = await response.text();
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
const errorJson = JSON.parse(errorText);
errorMessage = errorJson.error || errorMessage;
} catch {
// If not JSON, use the text as error message
errorMessage = errorText || errorMessage;
}
throw new Error(errorMessage);
}
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
throw new Error('Server returned non-JSON response. Expected JSON data.');
}
return response.json();
}
private async makeRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const defaultOptions: RequestInit = {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
};
const requestOptions = { ...defaultOptions, ...options };
try {
const response = await fetch(url, requestOptions);
return this.handleResponse<T>(response);
} catch (error) {
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error('Unable to connect to the server. Please check if the backend is running.');
}
throw error;
}
}
// Plant API methods
async getPlants(): Promise<Plant[]> {
return this.makeRequest<Plant[]>('/plants');
}
async createPlant(plant: Omit<Plant, 'id' | 'createdAt' | 'updatedAt'>): Promise<Plant> {
return this.makeRequest<Plant>('/plants', {
method: 'POST',
body: JSON.stringify(plant),
});
}
async updatePlant(id: number, plant: Partial<Plant>): Promise<Plant> {
return this.makeRequest<Plant>(`/plants/${id}`, {
method: 'PUT',
body: JSON.stringify(plant),
});
}
async deletePlant(id: number): Promise<void> {
return this.makeRequest<void>(`/plants/${id}`, {
method: 'DELETE',
});
}
// Task API methods
async getTasks(): Promise<Task[]> {
return this.makeRequest<Task[]>('/tasks');
}
async createTask(task: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Promise<Task> {
return this.makeRequest<Task>('/tasks', {
method: 'POST',
body: JSON.stringify(task),
});
}
async updateTask(id: number, task: Partial<Task>): Promise<Task> {
return this.makeRequest<Task>(`/tasks/${id}`, {
method: 'PUT',
body: JSON.stringify(task),
});
}
async deleteTask(id: number): Promise<void> {
return this.makeRequest<void>(`/tasks/${id}`, {
method: 'DELETE',
});
}
// Maintenance Record API methods
async getMaintenanceRecords(): Promise<MaintenanceRecord[]> {
return this.makeRequest<MaintenanceRecord[]>('/maintenance');
}
async createMaintenanceRecord(
record: Omit<MaintenanceRecord, 'id' | 'createdAt' | 'updatedAt'>
): Promise<MaintenanceRecord> {
return this.makeRequest<MaintenanceRecord>('/maintenance', {
method: 'POST',
body: JSON.stringify(record),
});
}
async updateMaintenanceRecord(
id: number,
record: Partial<MaintenanceRecord>
): Promise<MaintenanceRecord> {
return this.makeRequest<MaintenanceRecord>(`/maintenance/${id}`, {
method: 'PUT',
body: JSON.stringify(record),
});
}
async deleteMaintenanceRecord(id: number): Promise<void> {
return this.makeRequest<void>(`/maintenance/${id}`, {
method: 'DELETE',
});
}
// Harvest Record API methods
async getHarvestRecords(): Promise<HarvestRecord[]> {
return this.makeRequest<HarvestRecord[]>('/harvests');
}
async createHarvestRecord(
record: Omit<HarvestRecord, 'id' | 'createdAt' | 'updatedAt'>
): Promise<HarvestRecord> {
return this.makeRequest<HarvestRecord>('/harvests', {
method: 'POST',
body: JSON.stringify(record),
});
}
async updateHarvestRecord(
id: number,
record: Partial<HarvestRecord>
): Promise<HarvestRecord> {
return this.makeRequest<HarvestRecord>(`/harvests/${id}`, {
method: 'PUT',
body: JSON.stringify(record),
});
}
async deleteHarvestRecord(id: number): Promise<void> {
return this.makeRequest<void>(`/harvests/${id}`, {
method: 'DELETE',
});
}
// Plant History API methods
async getPlantHistory(plantId: number): Promise<PlantHistory[]> {
return this.makeRequest<PlantHistory[]>(`/plants/${plantId}/history`);
}
async createPlantHistory(
history: Omit<PlantHistory, 'id' | 'createdAt' | 'updatedAt'>
): Promise<PlantHistory> {
return this.makeRequest<PlantHistory>('/plant-history', {
method: 'POST',
body: JSON.stringify(history),
});
}
async updatePlantHistory(
id: number,
history: Partial<PlantHistory>
): Promise<PlantHistory> {
return this.makeRequest<PlantHistory>(`/plant-history/${id}`, {
method: 'PUT',
body: JSON.stringify(history),
});
}
// Health check
async healthCheck(): Promise<{ status: string; message: string }> {
return this.makeRequest<{ status: string; message: string }>('/health');
}
// Utility methods for batch operations
async getPlantWithDetails(plantId: number): Promise<{
plant: Plant;
history: PlantHistory[];
harvests: HarvestRecord[];
maintenance: MaintenanceRecord[];
}> {
const [plant, history, harvests, maintenance] = await Promise.all([
this.makeRequest<Plant>(`/plants/${plantId}`),
this.getPlantHistory(plantId),
this.makeRequest<HarvestRecord[]>(`/harvests?plantId=${plantId}`),
this.makeRequest<MaintenanceRecord[]>(`/maintenance?plantId=${plantId}`)
]);
return { plant, history, harvests, maintenance };
}
// Search and filter methods
async searchPlants(query: string): Promise<Plant[]> {
return this.makeRequest<Plant[]>(`/plants/search?q=${encodeURIComponent(query)}`);
}
async getPlantsByType(type: string): Promise<Plant[]> {
return this.makeRequest<Plant[]>(`/plants?type=${encodeURIComponent(type)}`);
}
async getPlantsByHealthStatus(status: string): Promise<Plant[]> {
return this.makeRequest<Plant[]>(`/plants?healthStatus=${encodeURIComponent(status)}`);
}
async getTasksByStatus(completed: boolean): Promise<Task[]> {
return this.makeRequest<Task[]>(`/tasks?completed=${completed}`);
}
async getUpcomingTasks(days: number = 7): Promise<Task[]> {
const endDate = new Date();
endDate.setDate(endDate.getDate() + days);
return this.makeRequest<Task[]>(`/tasks/upcoming?days=${days}`);
}
async getOverdueTasks(): Promise<Task[]> {
return this.makeRequest<Task[]>('/tasks/overdue');
}
// Statistics methods
async getPlantStatistics(plantId: number, year?: number): Promise<any> {
const yearParam = year ? `?year=${year}` : '';
return this.makeRequest<any>(`/plants/${plantId}/statistics${yearParam}`);
}
async getGardenSummary(): Promise<{
totalPlants: number;
healthyPlants: number;
plantsNeedingAttention: number;
totalTasks: number;
completedTasks: number;
upcomingTasks: number;
totalHarvests: number;
recentMaintenanceCount: number;
}> {
return this.makeRequest<any>('/dashboard/summary');
}
}
// Create and export a singleton instance
export const apiService = new ApiService();
// Export the class for testing purposes
export { ApiService };

87
src/types/index.ts Normal file
View File

@ -0,0 +1,87 @@
export interface Plant {
id: number;
type: string;
variety: string;
purchaseLocation: string;
seedlingAge: number;
seedlingHeight: number;
plantingDate: string;
healthStatus: 'good' | 'needs-attention' | 'dead';
currentHeight?: number;
photoUrl?: string;
currentPhotoUrl?: string;
notes: string;
createdAt: string;
updatedAt: string;
}
export interface PlantHistory {
id: number;
plantId: number;
year: number;
bloomingDate?: string;
fruitingDate?: string;
harvestDate?: string;
currentHeight?: number;
currentPhotoUrl?: string;
createdAt: string;
updatedAt: string;
}
export interface HarvestRecord {
id: number;
plantId: number;
date: string;
quantity: number;
unit: string;
notes: string;
createdAt: string;
updatedAt: string;
}
export interface MaintenanceRecord {
id: number;
plantId: number;
date: string;
type: 'chemical' | 'fertilizer' | 'watering' | 'pruning' | 'transplanting' | 'other';
description: string;
amount?: string;
isPlanned: boolean;
isCompleted: boolean;
createdAt: string;
updatedAt: string;
}
export interface Task {
id: number;
plantId?: number;
title: string;
description: string;
deadline: string;
completed: boolean;
createdAt: string;
updatedAt: string;
}
export interface PlantWithHistory extends Plant {
history: PlantHistory[];
harvests: HarvestRecord[];
maintenance: MaintenanceRecord[];
}
export interface PlantStatistics {
plantId: number;
year: number;
bloomingDates: string[];
fruitingDates: string[];
harvestDates: string[];
totalHarvest: number;
heightGrowth: number;
maintenanceCount: {
chemical: number;
fertilizer: number;
watering: number;
pruning: number;
};
completedTasks: number;
plannedTasks: number;
}

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"]
}

10
vite.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
});