Первый комит
This commit is contained in:
commit
9d154596ff
3
.bolt/config.json
Normal file
3
.bolt/config.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"template": "bolt-vite-react-ts"
|
||||||
|
}
|
5
.bolt/prompt
Normal file
5
.bolt/prompt
Normal 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
25
.gitignore
vendored
Normal 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
26
Dockerfile.frontend
Normal 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
171
README.md
Normal 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
19
backend/Dockerfile
Normal 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
2684
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
backend/package.json
Normal file
25
backend/package.json
Normal 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
142
backend/scripts/init-db.js
Normal 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
673
backend/server.js
Normal 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
44
docker-compose.yml
Normal 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
28
eslint.config.js
Normal 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
13
index.html
Normal 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
22
nginx.conf
Normal 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
4561
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
160
src/App.tsx
Normal file
160
src/App.tsx
Normal 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;
|
220
src/components/Dashboard.tsx
Normal file
220
src/components/Dashboard.tsx
Normal 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;
|
140
src/components/HarvestForm.tsx
Normal file
140
src/components/HarvestForm.tsx
Normal 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;
|
194
src/components/MaintenanceForm.tsx
Normal file
194
src/components/MaintenanceForm.tsx
Normal 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;
|
203
src/components/MaintenanceLog.tsx
Normal file
203
src/components/MaintenanceLog.tsx
Normal 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;
|
261
src/components/PlantForm.tsx
Normal file
261
src/components/PlantForm.tsx
Normal 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;
|
325
src/components/PlantRegistry.tsx
Normal file
325
src/components/PlantRegistry.tsx
Normal 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;
|
208
src/components/PlantStatistics.tsx
Normal file
208
src/components/PlantStatistics.tsx
Normal 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
168
src/components/TaskForm.tsx
Normal 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;
|
239
src/components/TaskPlanner.tsx
Normal file
239
src/components/TaskPlanner.tsx
Normal 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
3
src/index.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
10
src/main.tsx
Normal file
10
src/main.tsx
Normal 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
268
src/services/api.ts
Normal 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
87
src/types/index.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
8
tailwind.config.js
Normal file
8
tailwind.config.js
Normal 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
24
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal 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
10
vite.config.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user