Рабочая версия 0.2.0 Добавлена статистика, вид растений по карточкам и таблицей.

This commit is contained in:
anibilag 2025-08-10 23:46:53 +03:00
parent fee1e116d2
commit 12d0f3708d
25 changed files with 2710 additions and 370 deletions

Binary file not shown.

View File

@ -13,7 +13,8 @@
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2" "express": "^4.18.2",
"multer": "^1.4.5-lts.1"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1" "nodemon": "^3.0.1"
@ -46,6 +47,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@ -195,6 +202,23 @@
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
} }
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -265,6 +289,51 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"engines": [
"node >= 0.8"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/concat-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/concat-stream/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -301,6 +370,12 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": { "node_modules/cors": {
"version": "2.8.5", "version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
@ -834,6 +909,12 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -937,6 +1018,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mkdirp-classic": { "node_modules/mkdirp-classic": {
"version": "0.5.3", "version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@ -949,6 +1042,25 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": {
"version": "1.4.5-lts.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/napi-build-utils": { "node_modules/napi-build-utils": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
@ -1136,6 +1248,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -1484,6 +1602,14 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@ -1606,6 +1732,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/undefsafe": { "node_modules/undefsafe": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@ -1651,6 +1783,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
} }
} }
} }

View File

@ -14,7 +14,8 @@
"better-sqlite3": "^8.7.0", "better-sqlite3": "^8.7.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
"dotenv": "^16.3.1" "dotenv": "^16.3.1",
"multer": "^1.4.5-lts.1"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1" "nodemon": "^3.0.1"

View File

@ -41,6 +41,42 @@ try {
) )
`); `);
// Fertilizers table
db.exec(`
CREATE TABLE IF NOT EXISTS fertilizers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
brand TEXT,
type TEXT NOT NULL CHECK(type IN ('organic', 'synthetic', 'liquid', 'granular', 'slow-release')),
npk_ratio TEXT,
description TEXT,
application_rate TEXT,
frequency TEXT,
season TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Chemicals table
db.exec(`
CREATE TABLE IF NOT EXISTS chemicals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
brand TEXT,
type TEXT NOT NULL CHECK(type IN ('pesticide', 'herbicide', 'fungicide', 'insecticide', 'miticide')),
active_ingredient TEXT,
concentration TEXT,
target_pests TEXT,
application_method TEXT,
safety_period INTEGER,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Plant history table // Plant history table
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS plant_history ( CREATE TABLE IF NOT EXISTS plant_history (
@ -70,7 +106,6 @@ try {
) )
`); `);
// Maintenance records table
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS maintenance_records ( CREATE TABLE IF NOT EXISTS maintenance_records (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -79,9 +114,13 @@ try {
type TEXT NOT NULL CHECK(type IN ('chemical', 'fertilizer', 'watering', 'pruning', 'transplanting', 'other')), type TEXT NOT NULL CHECK(type IN ('chemical', 'fertilizer', 'watering', 'pruning', 'transplanting', 'other')),
description TEXT NOT NULL, description TEXT NOT NULL,
amount TEXT, amount TEXT,
fertilizer_id INTEGER,
chemical_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (plant_id) REFERENCES plants (id) ON DELETE CASCADE FOREIGN KEY (plant_id) REFERENCES plants (id) ON DELETE CASCADE,
FOREIGN KEY (fertilizer_id) REFERENCES fertilizers (id) ON DELETE SET NULL,
FOREIGN KEY (chemical_id) REFERENCES chemicals (id) ON DELETE SET NULL
) )
`); `);
@ -105,6 +144,28 @@ try {
// Insert sample data // Insert sample data
console.log('Inserting sample data...'); console.log('Inserting sample data...');
// Sample fertilizers
const insertFertilizer = db.prepare(`
INSERT INTO fertilizers (name, brand, type, npk_ratio, description, application_rate, frequency, season, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertFertilizer.run('All-Purpose Garden Fertilizer', 'Miracle-Gro', 'synthetic', '10-10-10', 'Balanced fertilizer for general garden use', '1 tablespoon per gallon', 'Every 2 weeks', 'Spring-Summer', 'Good for most plants');
insertFertilizer.run('Organic Compost', 'Local Farm', 'organic', '3-2-2', 'Natural organic matter for soil improvement', '2-3 inches layer', 'Twice yearly', 'Spring-Fall', 'Improves soil structure');
insertFertilizer.run('Bone Meal', 'Espoma', 'organic', '3-15-0', 'Slow-release phosphorus for root development', '1-2 tablespoons per plant', 'Once per season', 'Spring', 'Great for flowering plants');
insertFertilizer.run('Liquid Kelp', 'Neptune\'s Harvest', 'liquid', '0-0-1', 'Seaweed extract for plant health', '1 tablespoon per gallon', 'Monthly', 'All seasons', 'Boosts plant immunity');
// Sample chemicals
const insertChemical = db.prepare(`
INSERT INTO chemicals (name, brand, type, active_ingredient, concentration, target_pests, application_method, safety_period, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertChemical.run('Neem Oil', 'Garden Safe', 'insecticide', 'Azadirachtin', '0.9%', 'Aphids, whiteflies, spider mites', 'Foliar spray', 1, 'Organic option, safe for beneficial insects');
insertChemical.run('Copper Fungicide', 'Bonide', 'fungicide', 'Copper sulfate', '8%', 'Blight, rust, mildew', 'Foliar spray', 7, 'Use in early morning or evening');
insertChemical.run('Bt Spray', 'Safer Brand', 'pesticide', 'Bacillus thuringiensis', '0.5%', 'Caterpillars, larvae', 'Foliar spray', 0, 'Organic, targets specific pests');
insertChemical.run('Systemic Insecticide', 'Bayer', 'insecticide', 'Imidacloprid', '1.47%', 'Aphids, scale, thrips', 'Soil drench', 21, 'Long-lasting protection');
// Sample plants // Sample plants
const insertPlant = db.prepare(` const insertPlant = db.prepare(`
INSERT INTO plants (purchase_location, seedling_age, type, variety, seedling_height, planting_date, current_height, health_status, photo_url, current_photo_url, notes) INSERT INTO plants (purchase_location, seedling_age, type, variety, seedling_height, planting_date, current_height, health_status, photo_url, current_photo_url, notes)
@ -121,19 +182,21 @@ try {
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`); `);
insertTask.run(1, 'Apply fertilizer', 'Apply spring fertilizer to apple trees', '2024-03-15', 0); insertTask.run(1, 'fertilizer', 'Apply spring fertilizer', 'Apply all-purpose fertilizer to apple trees for spring growth', '2024-03-15', 0, 1, null);
insertTask.run(2, 'Prune blueberry bushes', 'Annual pruning before spring growth', '2024-02-28', 1); insertTask.run(2, 'pruning', 'Prune blueberry bushes', 'Annual pruning before spring growth', '2024-02-28', 1, null, null);
insertTask.run(3, 'Harvest basil', 'Regular harvest to encourage growth', '2024-06-01', 0); insertTask.run(3, 'harvesting', 'Harvest basil', 'Regular harvest to encourage growth', '2024-06-01', 0, null, null);
insertTask.run(1, 'chemical', 'Apply neem oil treatment', 'Preventive neem oil application for pest control', '2024-04-01', 0, null, 1);
// Sample maintenance records // Sample maintenance records
const insertMaintenance = db.prepare(` const insertMaintenance = db.prepare(`
INSERT INTO maintenance_records (plant_id, date, type, description, amount) INSERT INTO maintenance_records (plant_id, date, type, description, amount, fertilizer_id, chemical_id)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`); `);
insertMaintenance.run(1, '2024-01-10', 'pruning', 'Winter pruning - removed dead branches', null); insertMaintenance.run(1, '2024-01-10', 'pruning', 'Winter pruning - removed dead branches', null, null, null);
insertMaintenance.run(2, '2024-01-05', 'fertilizer', 'Applied organic compost', '2 cups'); insertMaintenance.run(2, '2024-01-05', 'fertilizer', 'Applied organic compost', '2 cups', 2, null);
insertMaintenance.run(3, '2024-05-15', 'watering', 'Deep watering during dry spell', '1 gallon'); insertMaintenance.run(3, '2024-05-15', 'watering', 'Deep watering during dry spell', '1 gallon', null, null);
insertMaintenance.run(1, '2024-03-20', 'chemical', 'Applied neem oil for aphid prevention', '2 tablespoons per gallon', null, 1);
// Sample harvest records // Sample harvest records
const insertHarvest = db.prepare(` const insertHarvest = db.prepare(`

View File

@ -4,6 +4,7 @@ const bodyParser = require('body-parser');
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const multer = require('multer');
require('dotenv').config(); require('dotenv').config();
const app = express(); const app = express();
@ -11,6 +12,13 @@ const PORT = process.env.PORT || 3001;
const DB_DIR = path.join(__dirname, 'database'); const DB_DIR = path.join(__dirname, 'database');
const DB_PATH = path.join(DB_DIR, 'gardentrack.db'); const DB_PATH = path.join(DB_DIR, 'gardentrack.db');
// Ensure uploads directory exists
const UPLOADS_DIR = path.join(__dirname, 'uploads');
if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
console.log('Created uploads directory');
}
// Ensure database directory exists // Ensure database directory exists
if (!fs.existsSync(DB_DIR)) { if (!fs.existsSync(DB_DIR)) {
fs.mkdirSync(DB_DIR, { recursive: true }); fs.mkdirSync(DB_DIR, { recursive: true });
@ -22,6 +30,39 @@ app.use(cors());
app.use(bodyParser.json()); app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.urlencoded({ extended: true }));
// Serve uploaded files statically
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, UPLOADS_DIR);
},
filename: function (req, file, cb) {
// Generate unique filename with timestamp
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const extension = path.extname(file.originalname);
cb(null, 'plant-' + uniqueSuffix + extension);
}
});
const fileFilter = (req, file, cb) => {
// Accept only image files
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed!'), false);
}
};
const upload = multer({
storage: storage,
fileFilter: fileFilter,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB limit
}
});
// Database connection and initialization // Database connection and initialization
let db; let db;
try { try {
@ -59,6 +100,43 @@ function initializeDatabase() {
`); `);
console.log('Plants table ready'); console.log('Plants table ready');
// Fertilizers table
db.exec(`
CREATE TABLE IF NOT EXISTS fertilizers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
brand TEXT,
type TEXT NOT NULL CHECK(type IN ('organic', 'synthetic', 'liquid', 'granular', 'slow-release')),
npk_ratio TEXT,
description TEXT,
application_rate TEXT,
frequency TEXT,
season TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
console.log('Fertilizers table ready');
// Chemicals table
db.exec(`
CREATE TABLE IF NOT EXISTS chemicals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
brand TEXT,
type TEXT NOT NULL CHECK(type IN ('pesticide', 'herbicide', 'fungicide', 'insecticide', 'miticide')),
active_ingredient TEXT,
concentration TEXT,
target_pests TEXT,
application_method TEXT,
safety_period INTEGER,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
console.log('Chemicals table ready');
// Plant observations table // Plant observations table
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS plant_observations ( CREATE TABLE IF NOT EXISTS plant_observations (
@ -121,11 +199,15 @@ function initializeDatabase() {
type TEXT NOT NULL CHECK(type IN ('chemical', 'fertilizer', 'watering', 'pruning', 'transplanting', 'other')), type TEXT NOT NULL CHECK(type IN ('chemical', 'fertilizer', 'watering', 'pruning', 'transplanting', 'other')),
description TEXT NOT NULL, description TEXT NOT NULL,
amount TEXT, amount TEXT,
fertilizer_id INTEGER,
chemical_id INTEGER,
is_planned BOOLEAN DEFAULT 0, is_planned BOOLEAN DEFAULT 0,
is_completed BOOLEAN DEFAULT 1, is_completed BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (plant_id) REFERENCES plants (id) ON DELETE CASCADE FOREIGN KEY (plant_id) REFERENCES plants (id) ON DELETE CASCADE,
FOREIGN KEY (fertilizer_id) REFERENCES fertilizers (id) ON DELETE SET NULL,
FOREIGN KEY (chemical_id) REFERENCES chemicals (id) ON DELETE SET NULL
) )
`); `);
console.log('Maintenance records table ready'); console.log('Maintenance records table ready');
@ -135,13 +217,18 @@ function initializeDatabase() {
CREATE TABLE IF NOT EXISTS tasks ( CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
plant_id INTEGER, plant_id INTEGER,
type TEXT DEFAULT 'general' CHECK(type IN ('general', 'fertilizer', 'chemical', 'watering', 'pruning', 'transplanting', 'harvesting', 'other')),
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT, description TEXT,
deadline DATE NOT NULL, deadline DATE NOT NULL,
completed BOOLEAN DEFAULT 0, completed BOOLEAN DEFAULT 0,
fertilizer_id INTEGER,
chemical_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (plant_id) REFERENCES plants (id) ON DELETE SET NULL FOREIGN KEY (plant_id) REFERENCES plants (id) ON DELETE SET NULL,
FOREIGN KEY (fertilizer_id) REFERENCES fertilizers (id) ON DELETE SET NULL,
FOREIGN KEY (chemical_id) REFERENCES chemicals (id) ON DELETE SET NULL
) )
`); `);
console.log('Tasks table ready'); console.log('Tasks table ready');
@ -164,6 +251,28 @@ function initializeDatabase() {
// Insert sample data // Insert sample data
function insertSampleData() { function insertSampleData() {
try { try {
// Sample fertilizers
const insertFertilizer = db.prepare(`
INSERT INTO fertilizers (name, brand, type, npk_ratio, description, application_rate, frequency, season, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertFertilizer.run('All-Purpose Garden Fertilizer', 'Miracle-Gro', 'synthetic', '10-10-10', 'Balanced fertilizer for general garden use', '1 tablespoon per gallon', 'Every 2 weeks', 'Spring-Summer', 'Good for most plants');
insertFertilizer.run('Organic Compost', 'Local Farm', 'organic', '3-2-2', 'Natural organic matter for soil improvement', '2-3 inches layer', 'Twice yearly', 'Spring-Fall', 'Improves soil structure');
insertFertilizer.run('Bone Meal', 'Espoma', 'organic', '3-15-0', 'Slow-release phosphorus for root development', '1-2 tablespoons per plant', 'Once per season', 'Spring', 'Great for flowering plants');
insertFertilizer.run('Liquid Kelp', 'Neptune\'s Harvest', 'liquid', '0-0-1', 'Seaweed extract for plant health', '1 tablespoon per gallon', 'Monthly', 'All seasons', 'Boosts plant immunity');
// Sample chemicals
const insertChemical = db.prepare(`
INSERT INTO chemicals (name, brand, type, active_ingredient, concentration, target_pests, application_method, safety_period, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertChemical.run('Neem Oil', 'Garden Safe', 'insecticide', 'Azadirachtin', '0.9%', 'Aphids, whiteflies, spider mites', 'Foliar spray', 1, 'Organic option, safe for beneficial insects');
insertChemical.run('Copper Fungicide', 'Bonide', 'fungicide', 'Copper sulfate', '8%', 'Blight, rust, mildew', 'Foliar spray', 7, 'Use in early morning or evening');
insertChemical.run('Bt Spray', 'Safer Brand', 'pesticide', 'Bacillus thuringiensis', '0.5%', 'Caterpillars, larvae', 'Foliar spray', 0, 'Organic, targets specific pests');
insertChemical.run('Systemic Insecticide', 'Bayer', 'insecticide', 'Imidacloprid', '1.47%', 'Aphids, scale, thrips', 'Soil drench', 21, 'Long-lasting protection');
// Sample plants // Sample plants
const insertPlant = db.prepare(` const insertPlant = db.prepare(`
INSERT INTO plants (type, variety, purchase_location, seedling_age, seedling_height, planting_date, health_status, current_height, notes) INSERT INTO plants (type, variety, purchase_location, seedling_age, seedling_height, planting_date, health_status, current_height, notes)
@ -186,13 +295,14 @@ function insertSampleData() {
// Sample maintenance records // Sample maintenance records
const insertMaintenance = db.prepare(` const insertMaintenance = db.prepare(`
INSERT INTO maintenance_records (plant_id, date, type, description, amount, is_planned, is_completed) INSERT INTO maintenance_records (plant_id, date, type, description, amount, fertilizer_id, chemical_id, is_planned, is_completed)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
insertMaintenance.run(1, '2024-01-10', 'pruning', 'Winter pruning - removed dead branches', null, 0, 1); insertMaintenance.run(1, '2024-01-10', 'pruning', 'Winter pruning - removed dead branches', null, null, null, 0, 1);
insertMaintenance.run(2, '2024-01-05', 'fertilizer', 'Applied organic compost', '2 cups', 0, 1); insertMaintenance.run(2, '2024-01-05', 'fertilizer', 'Applied organic compost', '2 cups', 2, null, 0, 1);
insertMaintenance.run(3, '2024-05-15', 'watering', 'Deep watering during dry spell', '1 gallon', 0, 1); insertMaintenance.run(3, '2024-05-15', 'watering', 'Deep watering during dry spell', '1 gallon', null, null, 0, 1);
insertMaintenance.run(1, '2024-03-20', 'chemical', 'Applied neem oil for aphid prevention', '2 tablespoons per gallon', null, 1, 0, 1);
// Sample harvest records // Sample harvest records
const insertHarvest = db.prepare(` const insertHarvest = db.prepare(`
@ -221,6 +331,21 @@ function insertSampleData() {
// Routes // Routes
// Photo upload endpoint
app.post('/api/upload-photo', upload.single('photo'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// Return the file path relative to the server
const photoUrl = `/uploads/${req.file.filename}`;
res.json({ photoUrl });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Plant Observations // Plant Observations
app.get('/api/observations', (req, res) => { app.get('/api/observations', (req, res) => {
try { try {
@ -433,16 +558,27 @@ app.delete('/api/plants/:id', (req, res) => {
// Tasks // Tasks
app.get('/api/tasks', (req, res) => { app.get('/api/tasks', (req, res) => {
try { try {
const stmt = db.prepare('SELECT * FROM tasks ORDER BY deadline ASC'); const stmt = db.prepare(`
SELECT t.*, f.name as fertilizer_name, c.name as chemical_name
FROM tasks t
LEFT JOIN fertilizers f ON t.fertilizer_id = f.id
LEFT JOIN chemicals c ON t.chemical_id = c.id
ORDER BY t.deadline ASC
`);
const rows = stmt.all(); const rows = stmt.all();
res.json(rows.map(row => ({ res.json(rows.map(row => ({
id: row.id, id: row.id,
plantId: row.plant_id, plantId: row.plant_id,
type: row.type,
title: row.title, title: row.title,
description: row.description, description: row.description,
deadline: row.deadline, deadline: row.deadline,
completed: Boolean(row.completed), completed: Boolean(row.completed),
fertilizerId: row.fertilizer_id,
chemicalId: row.chemical_id,
fertilizerName: row.fertilizer_name,
chemicalName: row.chemical_name,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at updatedAt: row.updated_at
}))); })));
@ -453,25 +589,36 @@ app.get('/api/tasks', (req, res) => {
app.post('/api/tasks', (req, res) => { app.post('/api/tasks', (req, res) => {
try { try {
const { plantId, title, description, deadline, completed = false } = req.body; const { plantId, type, title, description, deadline, completed = false, fertilizerId, chemicalId } = req.body;
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO tasks (plant_id, title, description, deadline, completed) INSERT INTO tasks (plant_id, type, title, description, deadline, completed, fertilizer_id, chemical_id)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`); `);
const result = stmt.run(plantId, title, description, deadline, completed ? 1 : 0); const result = stmt.run(plantId, type, title, description, deadline, completed ? 1 : 0, fertilizerId || null, chemicalId || null);
const getStmt = db.prepare('SELECT * FROM tasks WHERE id = ?'); const getStmt = db.prepare(`
SELECT t.*, f.name as fertilizer_name, c.name as chemical_name
FROM tasks t
LEFT JOIN fertilizers f ON t.fertilizer_id = f.id
LEFT JOIN chemicals c ON t.chemical_id = c.id
WHERE t.id = ?
`);
const row = getStmt.get(result.lastInsertRowid); const row = getStmt.get(result.lastInsertRowid);
res.json({ res.json({
id: row.id, id: row.id,
plantId: row.plant_id, plantId: row.plant_id,
type: row.type,
title: row.title, title: row.title,
description: row.description, description: row.description,
deadline: row.deadline, deadline: row.deadline,
completed: Boolean(row.completed), completed: Boolean(row.completed),
fertilizerId: row.fertilizer_id,
chemicalId: row.chemical_id,
fertilizerName: row.fertilizer_name,
chemicalName: row.chemical_name,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at updatedAt: row.updated_at
}); });
@ -482,26 +629,37 @@ app.post('/api/tasks', (req, res) => {
app.put('/api/tasks/:id', (req, res) => { app.put('/api/tasks/:id', (req, res) => {
try { try {
const { plantId, title, description, deadline, completed } = req.body; const { plantId, type, title, description, deadline, completed, fertilizerId, chemicalId } = req.body;
const stmt = db.prepare(` const stmt = db.prepare(`
UPDATE tasks SET plant_id = ?, title = ?, description = ?, UPDATE tasks SET plant_id = ?, type = ?, title = ?, description = ?,
deadline = ?, completed = ?, updated_at = CURRENT_TIMESTAMP deadline = ?, completed = ?, fertilizer_id = ?, chemical_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
`); `);
stmt.run(plantId, title, description, deadline, completed ? 1 : 0, req.params.id); stmt.run(plantId, type, title, description, deadline, completed ? 1 : 0, fertilizerId || null, chemicalId || null, req.params.id);
const getStmt = db.prepare('SELECT * FROM tasks WHERE id = ?'); const getStmt = db.prepare(`
SELECT t.*, f.name as fertilizer_name, c.name as chemical_name
FROM tasks t
LEFT JOIN fertilizers f ON t.fertilizer_id = f.id
LEFT JOIN chemicals c ON t.chemical_id = c.id
WHERE t.id = ?
`);
const row = getStmt.get(req.params.id); const row = getStmt.get(req.params.id);
res.json({ res.json({
id: row.id, id: row.id,
plantId: row.plant_id, plantId: row.plant_id,
type: row.type,
title: row.title, title: row.title,
description: row.description, description: row.description,
deadline: row.deadline, deadline: row.deadline,
completed: Boolean(row.completed), completed: Boolean(row.completed),
fertilizerId: row.fertilizer_id,
chemicalId: row.chemical_id,
fertilizerName: row.fertilizer_name,
chemicalName: row.chemical_name,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at updatedAt: row.updated_at
}); });
@ -523,7 +681,13 @@ app.delete('/api/tasks/:id', (req, res) => {
// Maintenance Records // Maintenance Records
app.get('/api/maintenance', (req, res) => { app.get('/api/maintenance', (req, res) => {
try { try {
const stmt = db.prepare('SELECT * FROM maintenance_records ORDER BY date DESC'); const stmt = db.prepare(`
SELECT mr.*, f.name as fertilizer_name, c.name as chemical_name
FROM maintenance_records mr
LEFT JOIN fertilizers f ON mr.fertilizer_id = f.id
LEFT JOIN chemicals c ON mr.chemical_id = c.id
ORDER BY mr.date DESC
`);
const rows = stmt.all(); const rows = stmt.all();
res.json(rows.map(row => ({ res.json(rows.map(row => ({
@ -533,6 +697,10 @@ app.get('/api/maintenance', (req, res) => {
type: row.type, type: row.type,
description: row.description, description: row.description,
amount: row.amount, amount: row.amount,
fertilizerId: row.fertilizer_id,
chemicalId: row.chemical_id,
fertilizerName: row.fertilizer_name,
chemicalName: row.chemical_name,
isPlanned: Boolean(row.is_planned), isPlanned: Boolean(row.is_planned),
isCompleted: Boolean(row.is_completed), isCompleted: Boolean(row.is_completed),
createdAt: row.created_at, createdAt: row.created_at,
@ -545,16 +713,22 @@ app.get('/api/maintenance', (req, res) => {
app.post('/api/maintenance', (req, res) => { app.post('/api/maintenance', (req, res) => {
try { try {
const { plantId, date, type, description, amount, isPlanned, isCompleted } = req.body; const { plantId, date, type, description, amount, fertilizerId, chemicalId, isPlanned, isCompleted } = req.body;
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO maintenance_records (plant_id, date, type, description, amount, is_planned, is_completed) INSERT INTO maintenance_records (plant_id, date, type, description, amount, fertilizer_id, chemical_id, is_planned, is_completed)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
const result = stmt.run(plantId, date, type, description, amount, isPlanned ? 1 : 0, isCompleted ? 1 : 0); const result = stmt.run(plantId, date, type, description, amount, fertilizerId || null, chemicalId || null, isPlanned ? 1 : 0, isCompleted ? 1 : 0);
const getStmt = db.prepare('SELECT * FROM maintenance_records WHERE id = ?'); const getStmt = db.prepare(`
SELECT mr.*, f.name as fertilizer_name, c.name as chemical_name
FROM maintenance_records mr
LEFT JOIN fertilizers f ON mr.fertilizer_id = f.id
LEFT JOIN chemicals c ON mr.chemical_id = c.id
WHERE mr.id = ?
`);
const row = getStmt.get(result.lastInsertRowid); const row = getStmt.get(result.lastInsertRowid);
res.json({ res.json({
@ -564,6 +738,10 @@ app.post('/api/maintenance', (req, res) => {
type: row.type, type: row.type,
description: row.description, description: row.description,
amount: row.amount, amount: row.amount,
fertilizerId: row.fertilizer_id,
chemicalId: row.chemical_id,
fertilizerName: row.fertilizer_name,
chemicalName: row.chemical_name,
isPlanned: Boolean(row.is_planned), isPlanned: Boolean(row.is_planned),
isCompleted: Boolean(row.is_completed), isCompleted: Boolean(row.is_completed),
createdAt: row.created_at, createdAt: row.created_at,
@ -629,6 +807,121 @@ app.get('/api/health', (req, res) => {
res.json({ status: 'OK', message: 'GardenTrack API is running' }); res.json({ status: 'OK', message: 'GardenTrack API is running' });
}); });
// Fertilizers
app.get('/api/fertilizers', (req, res) => {
try {
const stmt = db.prepare('SELECT * FROM fertilizers ORDER BY name ASC');
const rows = stmt.all();
res.json(rows.map(row => ({
id: row.id,
name: row.name,
brand: row.brand,
type: row.type,
npkRatio: row.npk_ratio,
description: row.description,
applicationRate: row.application_rate,
frequency: row.frequency,
season: row.season,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
})));
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/fertilizers', (req, res) => {
try {
const { name, brand, type, npkRatio, description, applicationRate, frequency, season, notes } = req.body;
const stmt = db.prepare(`
INSERT INTO fertilizers (name, brand, type, npk_ratio, description, application_rate, frequency, season, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(name, brand, type, npkRatio, description, applicationRate, frequency, season, notes);
const getStmt = db.prepare('SELECT * FROM fertilizers WHERE id = ?');
const row = getStmt.get(result.lastInsertRowid);
res.json({
id: row.id,
name: row.name,
brand: row.brand,
type: row.type,
npkRatio: row.npk_ratio,
description: row.description,
applicationRate: row.application_rate,
frequency: row.frequency,
season: row.season,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
});
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// Chemicals
app.get('/api/chemicals', (req, res) => {
try {
const stmt = db.prepare('SELECT * FROM chemicals ORDER BY name ASC');
const rows = stmt.all();
res.json(rows.map(row => ({
id: row.id,
name: row.name,
brand: row.brand,
type: row.type,
activeIngredient: row.active_ingredient,
concentration: row.concentration,
targetPests: row.target_pests,
applicationMethod: row.application_method,
safetyPeriod: row.safety_period,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
})));
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/chemicals', (req, res) => {
try {
const { name, brand, type, activeIngredient, concentration, targetPests, applicationMethod, safetyPeriod, notes } = req.body;
const stmt = db.prepare(`
INSERT INTO chemicals (name, brand, type, active_ingredient, concentration, target_pests, application_method, safety_period, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(name, brand, type, activeIngredient, concentration, targetPests, applicationMethod, safetyPeriod, notes);
const getStmt = db.prepare('SELECT * FROM chemicals WHERE id = ?');
const row = getStmt.get(result.lastInsertRowid);
res.json({
id: row.id,
name: row.name,
brand: row.brand,
type: row.type,
activeIngredient: row.active_ingredient,
concentration: row.concentration,
targetPests: row.target_pests,
applicationMethod: row.application_method,
safetyPeriod: row.safety_period,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
});
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// Additional endpoints for enhanced functionality // Additional endpoints for enhanced functionality
// Get plant by ID // Get plant by ID

View File

@ -1,7 +1,7 @@
{ {
"name": "vite-react-typescript-starter", "name": "vite-react-typescript-starter",
"private": true, "private": true,
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --mode development", "dev": "vite --mode development",

View File

@ -1,11 +1,13 @@
import React, { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Sprout, Calendar, CheckSquare, Activity, Plus, BookOpen } from 'lucide-react'; import {Sprout, Calendar, CheckSquare, Activity, BookOpen, Beaker, FlaskConical} from 'lucide-react';
import { Plant, Task, MaintenanceRecord, HarvestRecord, PlantObservation } from './types'; import { Plant, Task, MaintenanceRecord, HarvestRecord, PlantObservation } from './types';
import Dashboard from './components/Dashboard'; import Dashboard from './components/Dashboard';
import PlantRegistry from './components/PlantRegistry'; import PlantRegistry from './components/PlantRegistry';
import TaskPlanner from './components/TaskPlanner'; import TaskPlanner from './components/TaskPlanner';
import MaintenanceLog from './components/MaintenanceLog'; import MaintenanceLog from './components/MaintenanceLog';
import ObservationJournal from './components/ObservationJournal'; import ObservationJournal from './components/ObservationJournal';
import FertilizerRegistry from './components/FertilizerRegistry';
import ChemicalRegistry from './components/ChemicalRegistry';
import { apiService } from './services/api'; import { apiService } from './services/api';
function App() { function App() {
@ -56,6 +58,8 @@ function App() {
{ id: 'tasks', label: 'План работ', icon: CheckSquare }, { id: 'tasks', label: 'План работ', icon: CheckSquare },
{ id: 'maintenance', label: 'Журнал', icon: Calendar }, { id: 'maintenance', label: 'Журнал', icon: Calendar },
{ id: 'observations', label: 'Дневник', icon: BookOpen }, { id: 'observations', label: 'Дневник', icon: BookOpen },
{ id: 'fertilizers', label: 'Удобрения', icon: FlaskConical },
{ id: 'chemicals', label: 'Химикаты', icon: Beaker },
]; ];
if (loading) { if (loading) {
@ -141,6 +145,12 @@ function App() {
onObservationsChange={setObservations} onObservationsChange={setObservations}
/> />
)} )}
{activeTab === 'fertilizers' && (
<FertilizerRegistry />
)}
{activeTab === 'chemicals' && (
<ChemicalRegistry />
)}
</main> </main>
{/* Mobile Navigation */} {/* Mobile Navigation */}

View File

@ -0,0 +1,244 @@
import React, { useState, useEffect } from 'react';
import { Chemical } from '../types';
import { X } from 'lucide-react';
interface ChemicalFormProps {
chemical?: Chemical | null;
onSave: (chemical: Omit<Chemical, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void;
}
const ChemicalForm: React.FC<ChemicalFormProps> = ({ chemical, onSave, onCancel }) => {
const [formData, setFormData] = useState({
name: '',
brand: '',
type: 'pesticide' as Chemical['type'],
activeIngredient: '',
concentration: '',
targetPests: '',
applicationMethod: '',
safetyPeriod: '',
notes: ''
});
useEffect(() => {
if (chemical) {
setFormData({
name: chemical.name,
brand: chemical.brand || '',
type: chemical.type,
activeIngredient: chemical.activeIngredient || '',
concentration: chemical.concentration || '',
targetPests: chemical.targetPests || '',
applicationMethod: chemical.applicationMethod || '',
safetyPeriod: chemical.safetyPeriod?.toString() || '',
notes: chemical.notes || ''
});
}
}, [chemical]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
...formData,
brand: formData.brand || undefined,
activeIngredient: formData.activeIngredient || undefined,
concentration: formData.concentration || undefined,
targetPests: formData.targetPests || undefined,
applicationMethod: formData.applicationMethod || undefined,
safetyPeriod: formData.safetyPeriod ? parseInt(formData.safetyPeriod) : undefined,
notes: formData.notes || 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-2xl 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">
{chemical ? 'Сохранить' : 'Добавить'}
</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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Наименование *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="e.g., Neem Oil"
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="brand" className="block text-sm font-medium text-gray-700 mb-1">
Марка
</label>
<input
type="text"
id="brand"
name="brand"
value={formData.brand}
onChange={handleChange}
placeholder="e.g., Garden Safe"
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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="type" className="block text-sm font-medium text-gray-700 mb-1">
Тип *
</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="pesticide">Pesticide</option>
<option value="herbicide">Herbicide</option>
<option value="fungicide">Fungicide</option>
<option value="insecticide">Insecticide</option>
<option value="miticide">Miticide</option>
</select>
</div>
<div>
<label htmlFor="concentration" className="block text-sm font-medium text-gray-700 mb-1">
Концентрация
</label>
<input
type="text"
id="concentration"
name="concentration"
value={formData.concentration}
onChange={handleChange}
placeholder="e.g., 0.9%"
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="activeIngredient" className="block text-sm font-medium text-gray-700 mb-1">
Активные ингредиенты
</label>
<input
type="text"
id="activeIngredient"
name="activeIngredient"
value={formData.activeIngredient}
onChange={handleChange}
placeholder="e.g., Azadirachtin"
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="targetPests" className="block text-sm font-medium text-gray-700 mb-1">
Потив вредителей
</label>
<textarea
id="targetPests"
name="targetPests"
value={formData.targetPests}
onChange={handleChange}
placeholder="e.g., Aphids, whiteflies, spider mites"
rows={2}
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="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="applicationMethod" className="block text-sm font-medium text-gray-700 mb-1">
Способ нанесения
</label>
<input
type="text"
id="applicationMethod"
name="applicationMethod"
value={formData.applicationMethod}
onChange={handleChange}
placeholder="e.g., Foliar spray"
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="safetyPeriod" className="block text-sm font-medium text-gray-700 mb-1">
Безопасный период (дни)
</label>
<input
type="number"
id="safetyPeriod"
name="safetyPeriod"
value={formData.safetyPeriod}
onChange={handleChange}
min="0"
placeholder="e.g., 7"
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="notes" className="block text-sm font-medium text-gray-700 mb-1">
Заметки
</label>
<textarea
id="notes"
name="notes"
value={formData.notes}
onChange={handleChange}
placeholder="Safety instructions, usage notes, etc..."
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"
>
Отмена
</button>
<button
type="submit"
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors"
>
{chemical ? 'Сохранить' : 'Добавить'}
</button>
</div>
</form>
</div>
</div>
);
};
export default ChemicalForm;

View File

@ -0,0 +1,254 @@
import React, { useState, useEffect } from 'react';
import { Chemical } from '../types';
import { Plus, Search, Edit, Trash2, Beaker } from 'lucide-react';
import ChemicalForm from './ChemicalForm';
import { apiService } from '../services/api';
const ChemicalRegistry: React.FC = () => {
const [chemicals, setChemicals] = useState<Chemical[]>([]);
const [showForm, setShowForm] = useState(false);
const [editingChemical, setEditingChemical] = useState<Chemical | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState('all');
const [loading, setLoading] = useState(true);
useEffect(() => {
loadChemicals();
}, []);
const loadChemicals = async () => {
try {
setLoading(true);
const data = await apiService.getChemicals();
setChemicals(data);
} catch (error) {
console.error('Error loading chemicals:', error);
} finally {
setLoading(false);
}
};
const filteredChemicals = chemicals.filter(chemical => {
const matchesSearch = chemical.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(chemical.brand && chemical.brand.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesType = filterType === 'all' || chemical.type === filterType;
return matchesSearch && matchesType;
});
const handleSaveChemical = async (chemicalData: Omit<Chemical, 'id' | 'createdAt' | 'updatedAt'>) => {
try {
if (editingChemical) {
const updatedChemical = await apiService.updateChemical(editingChemical.id, chemicalData);
setChemicals(chemicals.map(c => c.id === editingChemical.id ? updatedChemical : c));
} else {
const newChemical = await apiService.createChemical(chemicalData);
setChemicals([...chemicals, newChemical]);
}
setShowForm(false);
setEditingChemical(null);
} catch (error) {
console.error('Error saving chemical:', error);
}
};
const handleDeleteChemical = async (chemicalId: number) => {
if (window.confirm('Are you sure you want to delete this chemical?')) {
try {
await apiService.deleteChemical(chemicalId);
setChemicals(chemicals.filter(c => c.id !== chemicalId));
} catch (error) {
console.error('Error deleting chemical:', error);
}
}
};
const getTypeColor = (type: string) => {
switch (type) {
case 'pesticide': return 'bg-red-100 text-red-800';
case 'herbicide': return 'bg-orange-100 text-orange-800';
case 'fungicide': return 'bg-yellow-100 text-yellow-800';
case 'insecticide': return 'bg-blue-100 text-blue-800';
case 'miticide': return 'bg-purple-100 text-purple-800';
default: return 'bg-gray-100 text-gray-800';
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold text-green-800">Chemical Registry</h2>
<p className="text-green-600 mt-1">Manage your chemical inventory and safety information</p>
</div>
<button
onClick={() => setShowForm(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>Новый химикат</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-2 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 chemicals..."
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">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="pesticide">Pesticide</option>
<option value="herbicide">Herbicide</option>
<option value="fungicide">Fungicide</option>
<option value="insecticide">Insecticide</option>
<option value="miticide">Miticide</option>
</select>
</div>
</div>
</div>
{/* Chemicals Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredChemicals.map((chemical) => (
<div key={chemical.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 className="flex items-center space-x-3">
<div className="bg-red-100 p-2 rounded-lg">
<Beaker className="h-5 w-5 text-red-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">{chemical.name}</h3>
{chemical.brand && (
<p className="text-sm text-gray-600">{chemical.brand}</p>
)}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => {
setEditingChemical(chemical);
setShowForm(true);
}}
className="p-1 text-blue-600 hover:text-blue-700 transition-colors"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteChemical(chemical.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">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Type:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(chemical.type)}`}>
{chemical.type}
</span>
</div>
{chemical.activeIngredient && (
<div>
<span className="text-sm text-gray-600">Активные ингредиенты:</span>
<p className="text-sm text-gray-900 mt-1">{chemical.activeIngredient}</p>
</div>
)}
{chemical.concentration && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Концентрация:</span>
<span className="text-sm font-medium text-gray-900">{chemical.concentration}</span>
</div>
)}
{chemical.targetPests && (
<div>
<span className="text-sm text-gray-600">Потив вредителей:</span>
<p className="text-sm text-gray-900 mt-1">{chemical.targetPests}</p>
</div>
)}
{chemical.applicationMethod && (
<div>
<span className="text-sm text-gray-600">Application:</span>
<p className="text-sm text-gray-900 mt-1">{chemical.applicationMethod}</p>
</div>
)}
{chemical.safetyPeriod && (
<div className="bg-yellow-50 p-3 rounded-lg">
<span className="text-sm font-medium text-yellow-800">Безопасный период:</span>
<p className="text-sm text-yellow-700 mt-1">{chemical.safetyPeriod} days</p>
</div>
)}
{chemical.notes && (
<div className="border-t pt-3">
<span className="text-sm text-gray-600">Заметки:</span>
<p className="text-sm text-gray-700 mt-1">{chemical.notes}</p>
</div>
)}
</div>
</div>
</div>
))}
</div>
{filteredChemicals.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">No chemicals found.</p>
<button
onClick={() => setShowForm(true)}
className="mt-4 text-green-600 hover:text-green-700 transition-colors"
>
Add your first chemical
</button>
</div>
)}
{/* Chemical Form Modal */}
{showForm && (
<ChemicalForm
chemical={editingChemical}
onSave={handleSaveChemical}
onCancel={() => {
setShowForm(false);
setEditingChemical(null);
}}
/>
)}
</div>
);
};
export default ChemicalRegistry;

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Plant, Task, MaintenanceRecord, HarvestRecord } from '../types'; import {Plant, Task, MaintenanceRecord, HarvestRecord, PlantTitles, Maintenances} from '../types';
import { Calendar, AlertTriangle, CheckCircle, Sprout, Scissors, Droplets } from 'lucide-react'; import { Calendar, AlertTriangle, CheckCircle, Sprout, Scissors, Droplets } from 'lucide-react';
interface DashboardProps { interface DashboardProps {
@ -72,8 +72,8 @@ const Dashboard: React.FC<DashboardProps> = ({
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h2 className="text-3xl font-bold text-green-800 mb-2">Garden Dashboard</h2> <h2 className="text-3xl font-bold text-green-800 mb-2">Сводка по саду</h2>
<p className="text-green-600">Welcome to your garden management center</p> <p className="text-green-600">Добро пожаловать в мой центр управления садом</p>
</div> </div>
{/* Stats Grid */} {/* Stats Grid */}
@ -133,7 +133,7 @@ const Dashboard: React.FC<DashboardProps> = ({
})} })}
</div> </div>
) : ( ) : (
<p className="text-gray-500 text-center py-8">Никаких предстоящих работ нет</p> <p className="text-gray-500 text-center py-8">Предстоящих работ нет</p>
)} )}
</div> </div>
</div> </div>
@ -159,7 +159,7 @@ const Dashboard: React.FC<DashboardProps> = ({
<AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5" /> <AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-gray-900">{plant.variety}</p> <p className="font-medium text-gray-900">{plant.variety}</p>
<p className="text-sm text-gray-600 capitalize">{plant.type}</p> <p className="text-sm text-gray-600 capitalize">{PlantTitles[plant.type]}</p>
{plant.notes && ( {plant.notes && (
<p className="text-sm text-gray-500 mt-1">{plant.notes}</p> <p className="text-sm text-gray-500 mt-1">{plant.notes}</p>
)} )}
@ -178,7 +178,7 @@ const Dashboard: React.FC<DashboardProps> = ({
<div className="bg-white rounded-lg shadow-sm border border-green-100"> <div className="bg-white rounded-lg shadow-sm border border-green-100">
<div className="p-6 border-b border-green-100"> <div className="p-6 border-b border-green-100">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-green-800">Недавнии работы</h3> <h3 className="text-lg font-semibold text-green-800">Недавние работы</h3>
<button <button
onClick={() => onNavigate('maintenance')} onClick={() => onNavigate('maintenance')}
className="text-sm text-green-600 hover:text-green-700 transition-colors" className="text-sm text-green-600 hover:text-green-700 transition-colors"
@ -197,7 +197,7 @@ const Dashboard: React.FC<DashboardProps> = ({
<div key={record.id} className="flex items-start space-x-3 p-3 bg-green-50 rounded-lg"> <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" /> <Icon className="h-5 w-5 text-green-600 mt-0.5" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 capitalize">{record.type}</p> <p className="font-medium text-gray-900 capitalize">{Maintenances[record.type]}</p>
<p className="text-sm text-gray-600">{plant?.variety}</p> <p className="text-sm text-gray-600">{plant?.variety}</p>
<p className="text-sm text-gray-500">{record.description}</p> <p className="text-sm text-gray-500">{record.description}</p>
<p className="text-xs text-gray-400 mt-1"> <p className="text-xs text-gray-400 mt-1">

View File

@ -0,0 +1,243 @@
import React, { useState, useEffect } from 'react';
import { Fertilizer } from '../types';
import { X } from 'lucide-react';
interface FertilizerFormProps {
fertilizer?: Fertilizer | null;
onSave: (fertilizer: Omit<Fertilizer, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void;
}
const FertilizerForm: React.FC<FertilizerFormProps> = ({ fertilizer, onSave, onCancel }) => {
const [formData, setFormData] = useState({
name: '',
brand: '',
type: 'organic' as Fertilizer['type'],
npkRatio: '',
description: '',
applicationRate: '',
frequency: '',
season: '',
notes: ''
});
useEffect(() => {
if (fertilizer) {
setFormData({
name: fertilizer.name,
brand: fertilizer.brand || '',
type: fertilizer.type,
npkRatio: fertilizer.npkRatio || '',
description: fertilizer.description || '',
applicationRate: fertilizer.applicationRate || '',
frequency: fertilizer.frequency || '',
season: fertilizer.season || '',
notes: fertilizer.notes || ''
});
}
}, [fertilizer]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
...formData,
brand: formData.brand || undefined,
npkRatio: formData.npkRatio || undefined,
description: formData.description || undefined,
applicationRate: formData.applicationRate || undefined,
frequency: formData.frequency || undefined,
season: formData.season || undefined,
notes: formData.notes || 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-2xl 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">
{fertilizer ? 'Редактировать данные' : 'Новое удобрение'}
</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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Наименование *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="e.g., All-Purpose Garden 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="brand" className="block text-sm font-medium text-gray-700 mb-1">
Марка
</label>
<input
type="text"
id="brand"
name="brand"
value={formData.brand}
onChange={handleChange}
placeholder="e.g., Miracle-Gro"
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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="type" className="block text-sm font-medium text-gray-700 mb-1">
Тип *
</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="organic">Органическое</option>
<option value="synthetic">Синтетическое</option>
<option value="liquid">Жидкое</option>
<option value="granular">Гранулированное</option>
<option value="slow-release">Медленного действия</option>
</select>
</div>
<div>
<label htmlFor="npkRatio" className="block text-sm font-medium text-gray-700 mb-1">
Азот-Фосфор-Калий (NPK)
</label>
<input
type="text"
id="npkRatio"
name="npkRatio"
value={formData.npkRatio}
onChange={handleChange}
placeholder="e.g., 10-10-10"
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="description" className="block text-sm font-medium text-gray-700 mb-1">
Описание
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
placeholder="Brief description of the fertilizer..."
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="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="applicationRate" className="block text-sm font-medium text-gray-700 mb-1">
Норма расхода
</label>
<input
type="text"
id="applicationRate"
name="applicationRate"
value={formData.applicationRate}
onChange={handleChange}
placeholder="e.g., 1 tablespoon per gallon"
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="frequency" className="block text-sm font-medium text-gray-700 mb-1">
Как часто применять
</label>
<input
type="text"
id="frequency"
name="frequency"
value={formData.frequency}
onChange={handleChange}
placeholder="e.g., Every 2 weeks"
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="season" className="block text-sm font-medium text-gray-700 mb-1">
Время года
</label>
<input
type="text"
id="season"
name="season"
value={formData.season}
onChange={handleChange}
placeholder="e.g., Spring-Summer"
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">
Заметки
</label>
<textarea
id="notes"
name="notes"
value={formData.notes}
onChange={handleChange}
placeholder="Additional notes or special instructions..."
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"
>
Отмена
</button>
<button
type="submit"
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors"
>
{fertilizer ? 'Сохранить' : 'Добавить'}
</button>
</div>
</form>
</div>
</div>
);
};
export default FertilizerForm;

View File

@ -0,0 +1,254 @@
import React, { useState, useEffect } from 'react';
import { Fertilizer } from '../types';
import { Plus, Search, Edit, Trash2, Sprout } from 'lucide-react';
import FertilizerForm from './FertilizerForm';
import { apiService } from '../services/api';
const FertilizerRegistry: React.FC = () => {
const [fertilizers, setFertilizers] = useState<Fertilizer[]>([]);
const [showForm, setShowForm] = useState(false);
const [editingFertilizer, setEditingFertilizer] = useState<Fertilizer | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState('all');
const [loading, setLoading] = useState(true);
useEffect(() => {
loadFertilizers();
}, []);
const loadFertilizers = async () => {
try {
setLoading(true);
const data = await apiService.getFertilizers();
setFertilizers(data);
} catch (error) {
console.error('Error loading fertilizers:', error);
} finally {
setLoading(false);
}
};
const filteredFertilizers = fertilizers.filter(fertilizer => {
const matchesSearch = fertilizer.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(fertilizer.brand && fertilizer.brand.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesType = filterType === 'all' || fertilizer.type === filterType;
return matchesSearch && matchesType;
});
const handleSaveFertilizer = async (fertilizerData: Omit<Fertilizer, 'id' | 'createdAt' | 'updatedAt'>) => {
try {
if (editingFertilizer) {
const updatedFertilizer = await apiService.updateFertilizer(editingFertilizer.id, fertilizerData);
setFertilizers(fertilizers.map(f => f.id === editingFertilizer.id ? updatedFertilizer : f));
} else {
const newFertilizer = await apiService.createFertilizer(fertilizerData);
setFertilizers([...fertilizers, newFertilizer]);
}
setShowForm(false);
setEditingFertilizer(null);
} catch (error) {
console.error('Error saving fertilizer:', error);
}
};
const handleDeleteFertilizer = async (fertilizerId: number) => {
if (window.confirm('Are you sure you want to delete this fertilizer?')) {
try {
await apiService.deleteFertilizer(fertilizerId);
setFertilizers(fertilizers.filter(f => f.id !== fertilizerId));
} catch (error) {
console.error('Error deleting fertilizer:', error);
}
}
};
const getTypeColor = (type: string) => {
switch (type) {
case 'organic': return 'bg-green-100 text-green-800';
case 'synthetic': return 'bg-blue-100 text-blue-800';
case 'liquid': return 'bg-cyan-100 text-cyan-800';
case 'granular': return 'bg-yellow-100 text-yellow-800';
case 'slow-release': return 'bg-purple-100 text-purple-800';
default: return 'bg-gray-100 text-gray-800';
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold text-green-800">Реестр удобрений</h2>
<p className="text-green-600 mt-1">Управляйте своими запасами удобрений</p>
</div>
<button
onClick={() => setShowForm(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>Новое удобрение</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-2 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 fertilizers..."
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">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="organic">Органическое</option>
<option value="synthetic">Синтетическое</option>
<option value="liquid">Жидкое</option>
<option value="granular">Гранулированное</option>
<option value="slow-release">Медленного действия</option>
</select>
</div>
</div>
</div>
{/* Fertilizers Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredFertilizers.map((fertilizer) => (
<div key={fertilizer.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 className="flex items-center space-x-3">
<div className="bg-green-100 p-2 rounded-lg">
<Sprout className="h-5 w-5 text-green-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">{fertilizer.name}</h3>
{fertilizer.brand && (
<p className="text-sm text-gray-600">{fertilizer.brand}</p>
)}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => {
setEditingFertilizer(fertilizer);
setShowForm(true);
}}
className="p-1 text-blue-600 hover:text-blue-700 transition-colors"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteFertilizer(fertilizer.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">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Type:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(fertilizer.type)}`}>
{fertilizer.type}
</span>
</div>
{fertilizer.npkRatio && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">NPK Ratio:</span>
<span className="text-sm font-medium text-gray-900">{fertilizer.npkRatio}</span>
</div>
)}
{fertilizer.applicationRate && (
<div>
<span className="text-sm text-gray-600">Норма расхода:</span>
<p className="text-sm text-gray-900 mt-1">{fertilizer.applicationRate}</p>
</div>
)}
{fertilizer.frequency && (
<div>
<span className="text-sm text-gray-600">Как часто применять:</span>
<p className="text-sm text-gray-900 mt-1">{fertilizer.frequency}</p>
</div>
)}
{fertilizer.season && (
<div>
<span className="text-sm text-gray-600">Время года:</span>
<p className="text-sm text-gray-900 mt-1">{fertilizer.season}</p>
</div>
)}
{fertilizer.description && (
<div className="border-t pt-3">
<span className="text-sm text-gray-600">Описание:</span>
<p className="text-sm text-gray-700 mt-1">{fertilizer.description}</p>
</div>
)}
{fertilizer.notes && (
<div className="border-t pt-3">
<span className="text-sm text-gray-600">Заметки:</span>
<p className="text-sm text-gray-700 mt-1">{fertilizer.notes}</p>
</div>
)}
</div>
</div>
</div>
))}
</div>
{filteredFertilizers.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">No fertilizers found.</p>
<button
onClick={() => setShowForm(true)}
className="mt-4 text-green-600 hover:text-green-700 transition-colors"
>
Add your first fertilizer
</button>
</div>
)}
{/* Fertilizer Form Modal */}
{showForm && (
<FertilizerForm
fertilizer={editingFertilizer}
onSave={handleSaveFertilizer}
onCancel={() => {
setShowForm(false);
setEditingFertilizer(null);
}}
/>
)}
</div>
);
};
export default FertilizerRegistry;

View File

@ -35,7 +35,7 @@ const HarvestForm: React.FC<HarvestFormProps> = ({ plant, onSave, onCancel }) =>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full"> <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"> <div className="flex justify-between items-center p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900"> <h3 className="text-lg font-semibold text-gray-900">
Record Harvest: {plant.variety} Урожай: {plant.variety}
</h3> </h3>
<button <button
onClick={onCancel} onClick={onCancel}
@ -48,7 +48,7 @@ const HarvestForm: React.FC<HarvestFormProps> = ({ plant, onSave, onCancel }) =>
<form onSubmit={handleSubmit} className="p-6 space-y-4"> <form onSubmit={handleSubmit} className="p-6 space-y-4">
<div> <div>
<label htmlFor="date" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="date" className="block text-sm font-medium text-gray-700 mb-1">
Harvest Date * Дата сбора *
</label> </label>
<input <input
type="date" type="date"
@ -64,7 +64,7 @@ const HarvestForm: React.FC<HarvestFormProps> = ({ plant, onSave, onCancel }) =>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label htmlFor="quantity" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="quantity" className="block text-sm font-medium text-gray-700 mb-1">
Quantity * Количество *
</label> </label>
<input <input
type="number" type="number"
@ -81,7 +81,7 @@ const HarvestForm: React.FC<HarvestFormProps> = ({ plant, onSave, onCancel }) =>
<div> <div>
<label htmlFor="unit" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="unit" className="block text-sm font-medium text-gray-700 mb-1">
Unit Единица
</label> </label>
<select <select
id="unit" id="unit"
@ -90,27 +90,25 @@ const HarvestForm: React.FC<HarvestFormProps> = ({ plant, onSave, onCancel }) =>
onChange={handleChange} 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" 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">Килограмм</option>
<option value="kg">Kilograms (kg)</option> <option value="pieces">Штука</option>
<option value="pieces">Pieces</option> <option value="bunches">Пучок</option>
<option value="bunches">Bunches</option> <option value="cups">Чашка</option>
<option value="cups">Cups</option> <option value="liters">Литр</option>
<option value="liters">Liters</option>
<option value="gallons">Gallons</option>
</select> </select>
</div> </div>
</div> </div>
<div> <div>
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">
Notes Заметки
</label> </label>
<textarea <textarea
id="notes" id="notes"
name="notes" name="notes"
value={formData.notes} value={formData.notes}
onChange={handleChange} onChange={handleChange}
placeholder="Quality, taste, storage notes..." placeholder="Характеристики качества, вкуса, хранения..."
rows={3} 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" 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"
/> />
@ -122,13 +120,13 @@ const HarvestForm: React.FC<HarvestFormProps> = ({ plant, onSave, onCancel }) =>
onClick={onCancel} onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors" className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
> >
Cancel Отмена
</button> </button>
<button <button
type="submit" type="submit"
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors" className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors"
> >
Record Harvest Сохранить
</button> </button>
</div> </div>
</form> </form>

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Plant, MaintenanceRecord } from '../types'; import {Plant, MaintenanceRecord, Fertilizer, Chemical, PlantTitles} from '../types';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { apiService } from '../services/api';
interface MaintenanceFormProps { interface MaintenanceFormProps {
plants: Plant[]; plants: Plant[];
@ -15,16 +16,40 @@ const MaintenanceForm: React.FC<MaintenanceFormProps> = ({ plants, onSave, onCan
type: 'other' as MaintenanceRecord['type'], type: 'other' as MaintenanceRecord['type'],
description: '', description: '',
amount: '', amount: '',
fertilizerId: '',
chemicalId: '',
isPlanned: false, isPlanned: false,
isCompleted: true isCompleted: true
}); });
const [fertilizers, setFertilizers] = useState<Fertilizer[]>([]);
const [chemicals, setChemicals] = useState<Chemical[]>([]);
React.useEffect(() => {
loadRegisters();
}, []);
const loadRegisters = async () => {
try {
const [fertilizersData, chemicalsData] = await Promise.all([
apiService.getFertilizers(),
apiService.getChemicals()
]);
setFertilizers(fertilizersData);
setChemicals(chemicalsData);
} catch (error) {
console.error('Error loading registers:', error);
}
};
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
onSave({ onSave({
...formData, ...formData,
plantId: parseInt(formData.plantId), plantId: parseInt(formData.plantId),
amount: formData.amount || undefined, amount: formData.amount || undefined,
fertilizerId: formData.fertilizerId ? parseInt(formData.fertilizerId) : undefined,
chemicalId: formData.chemicalId ? parseInt(formData.chemicalId) : undefined,
isPlanned: formData.isPlanned, isPlanned: formData.isPlanned,
isCompleted: formData.isCompleted isCompleted: formData.isCompleted
}); });
@ -67,7 +92,7 @@ const MaintenanceForm: React.FC<MaintenanceFormProps> = ({ plants, onSave, onCan
<option value="">Выбор растения</option> <option value="">Выбор растения</option>
{plants.map(plant => ( {plants.map(plant => (
<option key={plant.id} value={plant.id}> <option key={plant.id} value={plant.id}>
{plant.variety} ({plant.type}) {plant.variety} ({PlantTitles[plant.type]})
</option> </option>
))} ))}
</select> </select>
@ -125,6 +150,81 @@ const MaintenanceForm: React.FC<MaintenanceFormProps> = ({ plants, onSave, onCan
/> />
</div> </div>
{/* Fertilizer Selection */}
{formData.type === 'fertilizer' && (
<div>
<label htmlFor="fertilizerId" className="block text-sm font-medium text-gray-700 mb-1">
Выбор удобрения
</label>
<select
id="fertilizerId"
name="fertilizerId"
value={formData.fertilizerId}
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="">Выбор удобрения (не обязательно)</option>
{fertilizers.map(fertilizer => (
<option key={fertilizer.id} value={fertilizer.id}>
{fertilizer.name} {fertilizer.brand && `- ${fertilizer.brand}`} ({fertilizer.npkRatio})
</option>
))}
</select>
{formData.fertilizerId && (
<div className="mt-2 p-2 bg-green-50 rounded text-sm">
{(() => {
const selected = fertilizers.find(f => f.id === parseInt(formData.fertilizerId));
return selected ? (
<div>
<p><strong>Норма расхода:</strong> {selected.applicationRate}</p>
<p><strong>Как часто применять:</strong> {selected.frequency}</p>
{selected.notes && <p><strong>Заметки:</strong> {selected.notes}</p>}
</div>
) : null;
})()}
</div>
)}
</div>
)}
{/* Chemical Selection */}
{formData.type === 'chemical' && (
<div>
<label htmlFor="chemicalId" className="block text-sm font-medium text-gray-700 mb-1">
Выбор химикатов
</label>
<select
id="chemicalId"
name="chemicalId"
value={formData.chemicalId}
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="">Выбор химикатов (не обязательно)</option>
{chemicals.map(chemical => (
<option key={chemical.id} value={chemical.id}>
{chemical.name} {chemical.brand && `- ${chemical.brand}`} ({chemical.type})
</option>
))}
</select>
{formData.chemicalId && (
<div className="mt-2 p-2 bg-red-50 rounded text-sm">
{(() => {
const selected = chemicals.find(c => c.id === parseInt(formData.chemicalId));
return selected ? (
<div>
<p><strong>Активные ингредиенты:</strong> {selected.activeIngredient}</p>
<p><strong>Против вредителей:</strong> {selected.targetPests}</p>
<p><strong>Безопасный период:</strong> {selected.safetyPeriod} days</p>
{selected.notes && <p><strong>Заметки:</strong> {selected.notes}</p>}
</div>
) : null;
})()}
</div>
)}
</div>
)}
<div> <div>
<label htmlFor="amount" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="amount" className="block text-sm font-medium text-gray-700 mb-1">
Количество (необязательно) Количество (необязательно)

View File

@ -108,7 +108,7 @@ const MaintenanceLog: React.FC<MaintenanceLogProps> = ({
<option value="watering">Полив</option> <option value="watering">Полив</option>
<option value="pruning">Обрезка</option> <option value="pruning">Обрезка</option>
<option value="transplanting">Пересадка</option> <option value="transplanting">Пересадка</option>
<option value="other">Other</option> <option value="other">Другое</option>
</select> </select>
</div> </div>
@ -150,6 +150,16 @@ const MaintenanceLog: React.FC<MaintenanceLogProps> = ({
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(record.type)}`}> <span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(record.type)}`}>
{record.type.replace('-', ' ')} {record.type.replace('-', ' ')}
</span> </span>
{record.fertilizerName && (
<span className="px-2 py-1 bg-green-200 text-green-800 text-xs rounded-full">
{record.fertilizerName}
</span>
)}
{record.chemicalName && (
<span className="px-2 py-1 bg-red-200 text-red-800 text-xs rounded-full">
{record.chemicalName}
</span>
)}
</div> </div>
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
{new Date(record.date).toLocaleDateString()} {new Date(record.date).toLocaleDateString()}
@ -158,7 +168,7 @@ const MaintenanceLog: React.FC<MaintenanceLogProps> = ({
{plant && ( {plant && (
<p className="text-sm text-gray-600 mb-2"> <p className="text-sm text-gray-600 mb-2">
Plant: <span className="font-medium">{plant.variety}</span> Растение: <span className="font-medium">{plant.variety}</span>
</p> </p>
)} )}
@ -166,7 +176,7 @@ const MaintenanceLog: React.FC<MaintenanceLogProps> = ({
{record.amount && ( {record.amount && (
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Amount: <span className="font-medium">{record.amount}</span> Количество: <span className="font-medium">{record.amount}</span>
</p> </p>
)} )}
</div> </div>
@ -183,7 +193,7 @@ const MaintenanceLog: React.FC<MaintenanceLogProps> = ({
onClick={() => setShowMaintenanceForm(true)} onClick={() => setShowMaintenanceForm(true)}
className="mt-4 text-green-600 hover:text-green-700 transition-colors" className="mt-4 text-green-600 hover:text-green-700 transition-colors"
> >
Запишите свое первое действие в журнал Записать свое первое действие в журнал
</button> </button>
</div> </div>
)} )}

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Plant, PlantObservation } from '../types'; import {Plant, PlantObservation, PlantTitles} from '../types';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
interface ObservationFormProps { interface ObservationFormProps {
@ -82,10 +82,10 @@ const ObservationForm: React.FC<ObservationFormProps> = ({ observation, plants,
required 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" 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> <option value="">Выбор растения</option>
{plants.map(plant => ( {plants.map(plant => (
<option key={plant.id} value={plant.id}> <option key={plant.id} value={plant.id}>
{plant.variety} ({plant.type}) {plant.variety} ({PlantTitles[plant.type]})
</option> </option>
))} ))}
</select> </select>

View File

@ -236,7 +236,7 @@ const ObservationJournal: React.FC<ObservationJournalProps> = ({
onClick={() => setShowObservationForm(true)} onClick={() => setShowObservationForm(true)}
className="mt-4 text-green-600 hover:text-green-700 transition-colors" className="mt-4 text-green-600 hover:text-green-700 transition-colors"
> >
Добавте первое наблюдение Добавить первое наблюдение
</button> </button>
</div> </div>
)} )}

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Plant } from '../types'; import { Plant } from '../types';
import { X } from 'lucide-react'; import { X, Upload, Image } from 'lucide-react';
import { apiService } from '../services/api';
interface PlantFormProps { interface PlantFormProps {
plant?: Plant | null; plant?: Plant | null;
@ -21,6 +22,8 @@ const PlantForm: React.FC<PlantFormProps> = ({ plant, onSave, onCancel }) => {
photoUrl: '', photoUrl: '',
notes: '' notes: ''
}); });
const [uploading, setUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
useEffect(() => { useEffect(() => {
if (plant) { if (plant) {
@ -55,9 +58,32 @@ const PlantForm: React.FC<PlantFormProps> = ({ plant, onSave, onCancel }) => {
setFormData(prev => ({ ...prev, [name]: value })); setFormData(prev => ({ ...prev, [name]: value }));
}; };
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
}
};
const handleUploadPhoto = async () => {
if (!selectedFile) return;
try {
setUploading(true);
const result = await apiService.uploadPhoto(selectedFile);
setFormData(prev => ({ ...prev, photoUrl: result.photoUrl }));
setSelectedFile(null);
} catch (error) {
console.error('Error uploading photo:', error);
alert('Failed to upload photo. Please try again.');
} finally {
setUploading(false);
}
};
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <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="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[85vh] overflow-y-auto">
<div className="flex justify-between items-center p-6 border-b border-gray-200"> <div className="flex justify-between items-center p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900"> <h3 className="text-lg font-semibold text-gray-900">
{plant ? 'Редактирование данных' : 'Новое растение'} {plant ? 'Редактирование данных' : 'Новое растение'}
@ -71,66 +97,84 @@ const PlantForm: React.FC<PlantFormProps> = ({ plant, onSave, onCancel }) => {
</div> </div>
<form onSubmit={handleSubmit} className="p-6 space-y-4"> <form onSubmit={handleSubmit} className="p-6 space-y-4">
<div> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<label htmlFor="type" className="block text-sm font-medium text-gray-700 mb-1"> <div>
Тип растения * <label htmlFor="type" className="block text-sm font-medium text-gray-700 mb-1">
</label> Тип растения *
<select </label>
id="type" <select
name="type" id="type"
value={formData.type} name="type"
onChange={handleChange} value={formData.type}
required 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" 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="">Выбор типа</option> >
<option value="tree">Дерево</option> <option value="">Выбор типа</option>
<option value="shrub">Кустарник</option> <option value="tree">Дерево</option>
<option value="herb">Зелень</option> <option value="shrub">Кустарник</option>
<option value="vegetable">Овощ</option> <option value="herb">Зелень</option>
<option value="flower">Цветы</option> <option value="vegetable">Овощ</option>
<option value="vine">Виноградная лоза</option> <option value="flower">Цветы</option>
<option value="grass">Трава</option> <option value="grass">Трава</option>
<option value="other">Другое</option> <option value="other">Другое</option>
</select> </select>
</div>
<div>
<label htmlFor="variety" className="block text-sm font-medium text-gray-700 mb-1">
Вид/Сорт *
</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> </div>
<div> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<label htmlFor="variety" className="block text-sm font-medium text-gray-700 mb-1"> <div>
Вид/Сорт * <label htmlFor="purchaseLocation" className="block text-sm font-medium text-gray-700 mb-1">
</label> Место покупки *
<input </label>
type="text" <input
id="variety" type="text"
name="variety" id="purchaseLocation"
value={formData.variety} name="purchaseLocation"
onChange={handleChange} value={formData.purchaseLocation}
placeholder="e.g., Apple - Honeycrisp" onChange={handleChange}
required placeholder="e.g., Local Nursery, Garden Center"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent" required
/> 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="plantingDate" className="block text-sm font-medium text-gray-700 mb-1">
Дата посадки *
</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> </div>
<div> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<label htmlFor="purchaseLocation" className="block text-sm font-medium text-gray-700 mb-1">
Место покупки *
</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> <div>
<label htmlFor="seedlingAge" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="seedlingAge" className="block text-sm font-medium text-gray-700 mb-1">
Возраст саженца (месяцы) * Возраст саженца (м-цы) *
</label> </label>
<input <input
type="number" type="number"
@ -146,7 +190,7 @@ const PlantForm: React.FC<PlantFormProps> = ({ plant, onSave, onCancel }) => {
<div> <div>
<label htmlFor="seedlingHeight" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="seedlingHeight" className="block text-sm font-medium text-gray-700 mb-1">
Высота саженца (см) * Высота саженца (м) *
</label> </label>
<input <input
type="number" type="number"
@ -160,70 +204,127 @@ const PlantForm: React.FC<PlantFormProps> = ({ plant, onSave, onCancel }) => {
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent" 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="currentHeight" className="block text-sm font-medium text-gray-700 mb-1">
Текущая высота (м)
</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">
Состояние
</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">Хорошее</option>
<option value="needs-attention">Требует внимания</option>
<option value="dead">Погибло</option>
</select>
</div>
</div> </div>
<div>
<label htmlFor="plantingDate" className="block text-sm font-medium text-gray-700 mb-1"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
Дата посадки * <div>
</label> <label htmlFor="photoUrl" className="block text-sm font-medium text-gray-700 mb-1">
<input Фотография
type="date" </label>
id="plantingDate"
name="plantingDate" {/* Photo Upload Section */}
value={formData.plantingDate} <div className="space-y-3">
onChange={handleChange} <div className="flex items-center space-x-3">
required <input
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" type="file"
/> accept="image/*"
onChange={handleFileSelect}
className="hidden"
id="photo-upload"
/>
<label
htmlFor="photo-upload"
className="flex items-center space-x-2 px-4 py-2 bg-blue-50 text-blue-600 rounded-md cursor-pointer hover:bg-blue-100 transition-colors"
>
<Upload className="h-4 w-4" />
<span>Choose Photo</span>
</label>
{selectedFile && (
<button
type="button"
onClick={handleUploadPhoto}
disabled={uploading}
className="flex items-center space-x-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 transition-colors"
>
{uploading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
) : (
<Image className="h-4 w-4" />
)}
<span>{uploading ? 'Загружается...' : 'Загрузка'}</span>
</button>
)}
</div>
{selectedFile && (
<p className="text-sm text-gray-600">
Selected: {selectedFile.name}
</p>
)}
{/* Manual URL input as fallback */}
<div className="border-t pt-3">
<label className="block text-xs text-gray-500 mb-1">Or enter photo URL manually:</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 text-sm"
/>
</div>
</div>
</div>
<div>
{/* Photo Preview */}
{formData.photoUrl && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Photo Preview</label>
<img
src={formData.photoUrl.startsWith('/uploads')
? `${import.meta.env.VITE_API_URL || '/api'}${formData.photoUrl}`.replace('/api/uploads', '/uploads')
: formData.photoUrl
}
alt="Plant preview"
className="w-full h-48 object-cover rounded-lg border"
onError={(e) => {
console.log('Image failed to load: ' + e, formData.photoUrl);
}}
/>
</div>
)}
</div>
</div> </div>
<div> <div>
<label htmlFor="currentHeight" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="type" className="block text-sm font-medium text-gray-700 mb-1">
Текущая высота (см)
</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">
Состояние
</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">Хорошее</option>
<option value="needs-attention">Требует внимания</option>
<option value="dead">Погибло</option>
</select>
</div>
<div>
<label htmlFor="photoUrl" className="block text-sm font-medium text-gray-700 mb-1">
Фотография
</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">
Заметки Заметки
</label> </label>
<textarea <textarea
@ -231,7 +332,7 @@ const PlantForm: React.FC<PlantFormProps> = ({ plant, onSave, onCancel }) => {
name="notes" name="notes"
value={formData.notes} value={formData.notes}
onChange={handleChange} onChange={handleChange}
placeholder="Special notes about this plant..." placeholder="Специальные заметки об этом растении..."
rows={3} 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" 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"
/> />

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Plant, HarvestRecord } from '../types'; import {Plant, HarvestRecord, PlantTitles, Conditions, HarvestUnits} from '../types';
import { Plus, Search, Edit, Trash2, Calendar, Award, BarChart3 } from 'lucide-react'; import { Plus, Search, Edit, Trash2, Calendar, Award, BarChart3, Grid3X3, List } from 'lucide-react';
import PlantForm from './PlantForm'; import PlantForm from './PlantForm';
import HarvestForm from './HarvestForm'; import HarvestForm from './HarvestForm';
import PlantStatistics from './PlantStatistics'; import PlantStatistics from './PlantStatistics';
@ -30,6 +30,7 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState('all'); const [filterType, setFilterType] = useState('all');
const [filterStatus, setFilterStatus] = useState('all'); const [filterStatus, setFilterStatus] = useState('all');
const [viewMode, setViewMode] = useState<'cards' | 'table'>('cards');
const filteredPlants = plants.filter(plant => { const filteredPlants = plants.filter(plant => {
const matchesSearch = plant.variety.toLowerCase().includes(searchTerm.toLowerCase()) || const matchesSearch = plant.variety.toLowerCase().includes(searchTerm.toLowerCase()) ||
@ -100,13 +101,42 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
<h2 className="text-3xl font-bold text-green-800">Рестр растений</h2> <h2 className="text-3xl font-bold text-green-800">Рестр растений</h2>
<p className="text-green-600 mt-1">Управляйте своими садовыми растениями и следите за их развитием</p> <p className="text-green-600 mt-1">Управляйте своими садовыми растениями и следите за их развитием</p>
</div> </div>
<button
onClick={() => setShowPlantForm(true)} <div className="flex items-center space-x-3">
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center space-x-2 transition-colors" {/* View Mode Toggle */}
> <div className="flex bg-gray-100 rounded-lg p-1">
<Plus className="h-4 w-4" /> <button
<span>Новое растение</span> onClick={() => setViewMode('cards')}
</button> className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
viewMode === 'cards'
? 'bg-white text-green-600 shadow-sm'
: 'text-gray-600 hover:text-green-600'
}`}
>
<Grid3X3 className="h-4 w-4" />
<span>Карточки</span>
</button>
<button
onClick={() => setViewMode('table')}
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
viewMode === 'table'
? 'bg-white text-green-600 shadow-sm'
: 'text-gray-600 hover:text-green-600'
}`}
>
<List className="h-4 w-4" />
<span>Таблица</span>
</button>
</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>Новое растение</span>
</button>
</div>
</div> </div>
{/* Filters */} {/* Filters */}
@ -134,7 +164,7 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
> >
<option value="all">Все виды</option> <option value="all">Все виды</option>
{plantTypes.map(type => ( {plantTypes.map(type => (
<option key={type} value={type} className="capitalize">{type}</option> <option key={type} value={type} className="capitalize">{PlantTitles[type]}</option>
))} ))}
</select> </select>
</div> </div>
@ -154,7 +184,9 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
</div> </div>
</div> </div>
{/* Plants Grid */} {/* Plants Display */}
{viewMode === 'cards' ? (
/* Cards View */
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredPlants.map((plant) => { {filteredPlants.map((plant) => {
const plantHarvests = getPlantHarvests(plant.id); const plantHarvests = getPlantHarvests(plant.id);
@ -168,7 +200,7 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div> <div>
<h3 className="text-lg font-semibold text-gray-900">{plant.variety}</h3> <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-sm text-gray-600 capitalize">{PlantTitles[plant.type]}</p>
<p className="text-xs text-gray-500">Куплено в: {plant.purchaseLocation}</p> <p className="text-xs text-gray-500">Куплено в: {plant.purchaseLocation}</p>
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
@ -188,7 +220,7 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
setShowHarvestForm(true); setShowHarvestForm(true);
}} }}
className="p-1 text-green-600 hover:text-green-700 transition-colors" className="p-1 text-green-600 hover:text-green-700 transition-colors"
title="Add Harvest" title="Добавить урожай"
> >
<Award className="h-4 w-4" /> <Award className="h-4 w-4" />
</button> </button>
@ -210,11 +242,14 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
</div> </div>
</div> </div>
<div className="space-y-3"> <div className="space-y-2">
{plant.photoUrl && ( {plant.photoUrl && (
<div className="mb-3"> <div className="mb-3">
<img <img
src={plant.photoUrl} src={plant.photoUrl.startsWith('/uploads')
? `${import.meta.env.VITE_API_URL || '/api'}${plant.photoUrl}`.replace('/api/uploads', '/uploads')
: plant.photoUrl
}
alt={plant.variety} alt={plant.variety}
className="w-full h-32 object-cover rounded-lg" className="w-full h-32 object-cover rounded-lg"
onError={(e) => { onError={(e) => {
@ -224,28 +259,39 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
</div> </div>
)} )}
<div className="flex items-center space-x-2"> <div className="space-y-2">
<Calendar className="h-4 w-4 text-gray-400" /> <div className="flex items-center space-x-2">
<span className="text-sm text-gray-600"> <Calendar className="h-4 w-4 text-gray-400" />
Посажено: {new Date(plant.plantingDate).toLocaleDateString()} <span className="text-sm text-gray-600">
Посажено: {new Date(plant.plantingDate).toLocaleDateString()} ({Math.floor((new Date().getTime() - new Date(plant.plantingDate).getTime()) / (1000 * 60 * 60 * 24 * 365))} лет)
</span> </span>
</div> </div>
<div className="text-sm text-gray-600">
Возраст саженца: {plant.seedlingAge} месяцев
</div>
<div className="text-sm text-gray-600">
Высота саженца: {plant.seedlingHeight} м
</div>
<div className="grid grid-cols-2 gap-2 text-sm text-gray-600">
<div>Возраст: {plant.seedlingAge} months</div>
<div>Начальный: {plant.seedlingHeight}cm</div>
{plant.currentHeight && ( {plant.currentHeight && (
<> <div className="text-sm text-gray-600">
<div>Текущий: {plant.currentHeight}cm</div> Текущая высота: {plant.currentHeight} м
<div>Прирост: +{(plant.currentHeight - plant.seedlingHeight).toFixed(1)}cm</div> </div>
</> )}
{plant.currentHeight && (
<div className="text-sm text-gray-600">
Прирост: +{(plant.currentHeight - plant.seedlingHeight).toFixed(1)} м
</div>
)} )}
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Состояние:</span> <span className="text-sm text-gray-600">Состояние:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(plant.healthStatus)}`}> <span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(plant.healthStatus)}`}>
{plant.healthStatus.replace('-', ' ')} {Conditions[plant.healthStatus].replace('-', ' ')}
</span> </span>
</div> </div>
@ -253,7 +299,7 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
<div className="bg-green-50 p-3 rounded-lg"> <div className="bg-green-50 p-3 rounded-lg">
<p className="text-sm font-medium text-green-800">Последний урожай:</p> <p className="text-sm font-medium text-green-800">Последний урожай:</p>
<p className="text-sm text-green-600"> <p className="text-sm text-green-600">
{lastHarvest.quantity} {lastHarvest.unit} on {new Date(lastHarvest.date).toLocaleDateString()} {lastHarvest.quantity} {HarvestUnits[lastHarvest.unit]} {new Date(lastHarvest.date).toLocaleDateString()}
</p> </p>
</div> </div>
)} )}
@ -269,6 +315,151 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
); );
})} })}
</div> </div>
) : (
/* Table View */
<div className="bg-white rounded-lg shadow-sm border border-green-100 overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Plant
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Planted
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Height
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Harvest
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{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 (
<tr key={plant.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{plant.photoUrl && (
<div className="flex-shrink-0 h-10 w-10 mr-3">
<img
src={plant.photoUrl.startsWith('/uploads')
? `${import.meta.env.VITE_API_URL || '/api'}${plant.photoUrl}`.replace('/api/uploads', '/uploads')
: plant.photoUrl
}
alt={plant.variety}
className="h-10 w-10 rounded-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
<div>
<div className="text-sm font-medium text-gray-900">{plant.variety}</div>
<div className="text-sm text-gray-500">{plant.purchaseLocation}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-900 capitalize">{plant.type}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{new Date(plant.plantingDate).toLocaleDateString()}
</div>
<div className="text-sm text-gray-500">
({Math.floor((new Date().getTime() - new Date(plant.plantingDate).getTime()) / (1000 * 60 * 60 * 24 * 365))} years)
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{plant.currentHeight ? `${plant.currentHeight}cm` : `${plant.seedlingHeight}cm`}
</div>
{plant.currentHeight && (
<div className="text-sm text-gray-500">
+{(plant.currentHeight - plant.seedlingHeight).toFixed(1)}cm growth
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(plant.healthStatus)}`}>
{plant.healthStatus.replace('-', ' ')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{lastHarvest ? (
<div className="text-sm text-gray-900">
<div>{lastHarvest.quantity} {lastHarvest.unit}</div>
<div className="text-gray-500">{new Date(lastHarvest.date).toLocaleDateString()}</div>
</div>
) : (
<span className="text-sm text-gray-500">No harvests</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<button
onClick={() => {
setSelectedPlantForStats(plant);
setShowStatistics(true);
}}
className="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="text-green-600 hover:text-green-700 transition-colors"
title="Добавить урожай"
>
<Award className="h-4 w-4" />
</button>
<button
onClick={() => {
setEditingPlant(plant);
setShowPlantForm(true);
}}
className="text-blue-600 hover:text-blue-700 transition-colors"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeletePlant(plant.id)}
className="text-red-600 hover:text-red-700 transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{filteredPlants.length === 0 && ( {filteredPlants.length === 0 && (
<div className="text-center py-12"> <div className="text-center py-12">
@ -277,7 +468,7 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
onClick={() => setShowPlantForm(true)} onClick={() => setShowPlantForm(true)}
className="mt-4 text-green-600 hover:text-green-700 transition-colors" className="mt-4 text-green-600 hover:text-green-700 transition-colors"
> >
Добавьте первое растение Добавить первое растение
</button> </button>
</div> </div>
)} )}

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Plant, PlantStatistics, HarvestRecord, MaintenanceRecord } from '../types'; import {Plant, PlantStatistics, HarvestRecord, MaintenanceRecord, HarvestUnits} from '../types';
import { BarChart3, TrendingUp, Calendar, Award } from 'lucide-react'; import { BarChart3, TrendingUp, Calendar, Award } from 'lucide-react';
interface PlantStatisticsProps { interface PlantStatisticsProps {
@ -75,7 +75,7 @@ const PlantStatisticsComponent: React.FC<PlantStatisticsProps> = ({
<div className="flex justify-between items-center p-6 border-b border-gray-200"> <div className="flex justify-between items-center p-6 border-b border-gray-200">
<div> <div>
<h3 className="text-xl font-semibold text-gray-900"> <h3 className="text-xl font-semibold text-gray-900">
{plant.variety} - Statistics {plant.variety} - Статистика
</h3> </h3>
<p className="text-sm text-gray-600">Год: {selectedYear}</p> <p className="text-sm text-gray-600">Год: {selectedYear}</p>
</div> </div>
@ -121,9 +121,9 @@ const PlantStatisticsComponent: React.FC<PlantStatisticsProps> = ({
<div className="bg-blue-50 p-4 rounded-lg"> <div className="bg-blue-50 p-4 rounded-lg">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-blue-600">Прибавка в росте</p> <p className="text-sm font-medium text-blue-600">Прирост</p>
<p className="text-2xl font-bold text-blue-800"> <p className="text-2xl font-bold text-blue-800">
+{statistics.heightGrowth.toFixed(1)}cm +{statistics.heightGrowth.toFixed(1)} м
</p> </p>
</div> </div>
<TrendingUp className="h-8 w-8 text-blue-600" /> <TrendingUp className="h-8 w-8 text-blue-600" />
@ -133,7 +133,7 @@ const PlantStatisticsComponent: React.FC<PlantStatisticsProps> = ({
<div className="bg-purple-50 p-4 rounded-lg"> <div className="bg-purple-50 p-4 rounded-lg">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-purple-600">Сбор урожая</p> <p className="text-sm font-medium text-purple-600">Сборов урожая</p>
<p className="text-2xl font-bold text-purple-800"> <p className="text-2xl font-bold text-purple-800">
{statistics.harvestDates.length} {statistics.harvestDates.length}
</p> </p>
@ -181,18 +181,25 @@ const PlantStatisticsComponent: React.FC<PlantStatisticsProps> = ({
{/* Harvest Timeline */} {/* Harvest Timeline */}
{statistics.harvestDates.length > 0 && ( {statistics.harvestDates.length > 0 && (
<div className="bg-green-50 p-6 rounded-lg"> <div className="bg-green-50 p-6 rounded-lg">
<h4 className="text-lg font-semibold text-gray-900 mb-4">Harvest Timeline</h4> <h4 className="text-lg font-semibold text-gray-900 mb-4">График сбора урожая</h4>
<div className="space-y-2"> <div className="space-y-2">
{statistics.harvestDates.map((date, index) => { {statistics.harvestDates.map((date, index) => {
const harvest = harvestRecords.find(h => h.date === date && h.plantId === plant.id); const harvest = harvestRecords.find(h => h.date === date && h.plantId === plant.id);
return ( return (
<div key={index} className="flex justify-between items-center py-2 px-3 bg-white rounded"> <div key={index} className="py-3 px-4 bg-white rounded-lg border border-green-100">
<span className="text-sm text-gray-600"> <div className="flex justify-between items-start mb-2">
{new Date(date).toLocaleDateString()} <span className="text-sm font-medium text-gray-900">
</span> {new Date(date).toLocaleDateString()}
<span className="text-sm font-medium text-green-600"> </span>
{harvest?.quantity} {harvest?.unit} <span className="text-sm font-medium text-green-600">
</span> {harvest?.quantity} {HarvestUnits[harvest?.unit || '']}
</span>
</div>
{harvest?.notes && (
<p className="text-sm text-gray-600 mt-1">
{harvest.notes}
</p>
)}
</div> </div>
); );
})} })}

View File

@ -1,168 +1,338 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Plant, Task } from '../types'; import { X, FileText, Sprout, Beaker } from 'lucide-react';
import { X } from 'lucide-react'; import {Task, Plant, Fertilizer, Chemical, PlantTitles, TaskDraft} from '../types';
import { apiService } from '../services/api';
interface TaskFormProps { interface TaskFormProps {
task?: Task | null; isOpen: boolean;
plants: Plant[]; onClose: () => void;
onSave: (task: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>) => void; onSubmit: (task: TaskDraft) => void;
onCancel: () => void; task?: Task;
} }
const TaskForm: React.FC<TaskFormProps> = ({ task, plants, onSave, onCancel }) => { const taskTypes = [
{ value: 'general', label: 'Общая', icon: FileText, color: 'bg-gray-100 text-gray-800' },
{ value: 'fertilizer', label: 'Внесение удобрений', icon: Sprout, color: 'bg-green-100 text-green-800' },
{ value: 'chemical', label: 'Применение химикатов', icon: Beaker, color: 'bg-red-100 text-red-800' },
{ value: 'watering', label: 'Полив', icon: FileText, color: 'bg-blue-100 text-blue-800' },
{ value: 'pruning', label: 'Обрезка', icon: FileText, color: 'bg-purple-100 text-purple-800' },
{ value: 'transplanting', label: 'Пересадка', icon: FileText, color: 'bg-orange-100 text-orange-800' },
{ value: 'harvesting', label: 'Сбор урожая', icon: FileText, color: 'bg-yellow-100 text-yellow-800' },
{ value: 'other', label: 'Другое', icon: FileText, color: 'bg-gray-100 text-gray-800' }
];
export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormProps) {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
plantId: '', plantId: '',
type: 'general',
title: '', title: '',
description: '', description: '',
deadline: '', deadline: '',
completed: false completed: false,
fertilizerId: '',
chemicalId: ''
}); });
const [plants, setPlants] = useState<Plant[]>([]);
const [fertilizers, setFertilizers] = useState<Fertilizer[]>([]);
const [chemicals, setChemicals] = useState<Chemical[]>([]);
const [selectedFertilizer, setSelectedFertilizer] = useState<Fertilizer | null>(null);
const [selectedChemical, setSelectedChemical] = useState<Chemical | null>(null);
useEffect(() => {
if (isOpen) {
loadData();
}
}, [isOpen]);
useEffect(() => { useEffect(() => {
if (task) { if (task) {
setFormData({ setFormData({
plantId: task.plantId?.toString() || '', plantId: task.plantId?.toString() || '',
type: task.type || 'general',
title: task.title, title: task.title,
description: task.description, description: task.description || '',
deadline: task.deadline, deadline: task.deadline.split('T')[0],
completed: task.completed completed: task.completed,
fertilizerId: task.fertilizerId?.toString() || '',
chemicalId: task.chemicalId?.toString() || ''
});
} else {
setFormData({
plantId: '',
type: 'general',
title: '',
description: '',
deadline: '',
completed: false,
fertilizerId: '',
chemicalId: ''
}); });
} }
}, [task]); }, [task]);
const handleSubmit = (e: React.FormEvent) => { const loadData = async () => {
e.preventDefault(); try {
onSave({ const [plantsData, fertilizersData, chemicalsData] = await Promise.all([
...formData, apiService.getPlants(),
plantId: formData.plantId ? parseInt(formData.plantId) : undefined apiService.getFertilizers(),
}); apiService.getChemicals()
]);
setPlants(plantsData);
setFertilizers(fertilizersData);
setChemicals(chemicalsData);
} catch (error) {
console.error('Error loading data:', error);
}
}; };
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => { useEffect(() => {
const { name, value, type } = e.target; if (formData.fertilizerId) {
const fertilizer = fertilizers.find(f => f.id.toString() === formData.fertilizerId);
setSelectedFertilizer(fertilizer || null);
} else {
setSelectedFertilizer(null);
}
}, [formData.fertilizerId, fertilizers]);
useEffect(() => {
if (formData.chemicalId) {
const chemical = chemicals.find(c => c.id.toString() === formData.chemicalId);
setSelectedChemical(chemical || null);
} else {
setSelectedChemical(null);
}
}, [formData.chemicalId, chemicals]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const taskData = {
...formData,
plantId: formData.plantId ? parseInt(formData.plantId) : undefined,
fertilizerId: formData.fertilizerId ? parseInt(formData.fertilizerId) : undefined,
chemicalId: formData.chemicalId ? parseInt(formData.chemicalId) : undefined
};
onSubmit(taskData);
onClose();
};
const handleTaskTypeChange = (taskType: string) => {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value type: taskType,
fertilizerId: taskType !== 'fertilizer' ? '' : prev.fertilizerId,
chemicalId: taskType !== 'chemical' ? '' : prev.chemicalId
})); }));
}; };
if (!isOpen) return null;
const selectedTaskType = taskTypes.find(type => type.value === formData.type);
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto"> <div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center p-6 border-b border-gray-200"> <div className="flex items-center justify-between p-6 border-b">
<h3 className="text-lg font-semibold text-gray-900"> <h2 className="text-xl font-semibold text-gray-900">
{task ? 'Редактирование данных работы' : 'Добавление новой работы'} {task ? 'Edit Task' : 'Create New Task'}
</h3> </h2>
<button <button
onClick={onCancel} onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors" className="text-gray-400 hover:text-gray-600 transition-colors"
> >
<X className="h-5 w-5" /> <X className="w-6 h-6" />
</button> </button>
</div> </div>
<form onSubmit={handleSubmit} className="p-6 space-y-4"> <form onSubmit={handleSubmit} className="p-6 space-y-6">
<div> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1"> <div>
Наименование работы * <label className="block text-sm font-medium text-gray-700 mb-2">
</label> Task Title *
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleChange}
placeholder="например, Внести удобрение"
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">
Растение (опционально)
</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="">Выбор растения (опционально)</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">
Срок исполнения *
</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">
Описание
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
placeholder="Детальное описание работы..."
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">
Отметить как выполненное
</label> </label>
<input
type="text"
required
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: 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"
placeholder="Enter task title"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Type *
</label>
<select
required
value={formData.type}
onChange={(e) => handleTaskTypeChange(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"
>
{taskTypes.map(type => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Deadline *
</label>
<input
type="date"
required
value={formData.deadline}
onChange={(e) => setFormData(prev => ({ ...prev, deadline: 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Status
</label>
<div className="flex items-center">
<input
type="checkbox"
id="completed"
checked={formData.completed}
onChange={(e) => setFormData(prev => ({ ...prev, completed: e.target.checked }))}
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">
Completed
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Plant (Optional)
</label>
<select
value={formData.plantId}
onChange={(e) => setFormData(prev => ({ ...prev, plantId: 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"
>
<option value="">Выбор растения</option>
{plants.map(plant => (
<option key={plant.id} value={plant.id}>
{plant.variety} ({PlantTitles[plant.type]})
</option>
))}
</select>
</div>
</div>
{formData.type === 'fertilizer' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Выбор удобрения
</label>
<select
value={formData.fertilizerId}
onChange={(e) => setFormData(prev => ({ ...prev, fertilizerId: 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"
>
<option value="">Выбор удобрения</option>
{fertilizers.map(fertilizer => (
<option key={fertilizer.id} value={fertilizer.id}>
{fertilizer.name} - {fertilizer.brand}
</option>
))}
</select>
{selectedFertilizer && (
<div className="mt-3 p-3 bg-green-50 rounded-md">
<h4 className="font-medium text-green-800 mb-2">Fertilizer Information</h4>
<div className="text-sm text-green-700 space-y-1">
<p><strong>NPK:</strong> {selectedFertilizer.npkRatio}</p>
<p><strong>Application Rate:</strong> {selectedFertilizer.applicationRate}</p>
<p><strong>Frequency:</strong> {selectedFertilizer.frequency}</p>
{selectedFertilizer.notes && (
<p><strong>Notes:</strong> {selectedFertilizer.notes}</p>
)}
</div>
</div>
)}
</div> </div>
)} )}
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200"> {formData.type === 'chemical' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Выбор химикатов
</label>
<select
value={formData.chemicalId}
onChange={(e) => setFormData(prev => ({ ...prev, chemicalId: 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"
>
<option value="">Выбор химикатов</option>
{chemicals.map(chemical => (
<option key={chemical.id} value={chemical.id}>
{chemical.name} - {chemical.brand}
</option>
))}
</select>
{selectedChemical && (
<div className="mt-3 p-3 bg-red-50 rounded-md">
<h4 className="font-medium text-red-800 mb-2">Chemical Information</h4>
<div className="text-sm text-red-700 space-y-1">
<p><strong>Active Ingredients:</strong> {selectedChemical.activeIngredient}</p>
<p><strong>Target Pests:</strong> {selectedChemical.targetPests}</p>
<p><strong>Application Rate:</strong> {selectedChemical.applicationMethod}</p>
{selectedChemical.safetyPeriod && (
<p className="bg-yellow-100 text-yellow-800 p-2 rounded">
<strong>Safety Period:</strong> {selectedChemical.safetyPeriod}
</p>
)}
{selectedChemical.notes && (
<p><strong>Notes:</strong> {selectedChemical.notes}</p>
)}
</div>
</div>
)}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
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"
placeholder="Enter task description"
/>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t">
<button <button
type="button" type="button"
onClick={onCancel} onClick={onClose}
className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors" className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
> >
Отмена Cancel
</button> </button>
<button <button
type="submit" type="submit"
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors" className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
> >
{task ? 'Сохранить' : 'Создать'} {task ? 'Update Task' : 'Create Task'}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
); );
}; }
export default TaskForm;

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Plant, Task } from '../types'; import {Plant, Task, TaskTitles} from '../types';
import { Plus, Calendar, CheckCircle, Clock, Edit, Trash2 } from 'lucide-react'; import { Plus, Calendar, CheckCircle, Clock, Edit, Trash2, FileText, Sprout, Beaker } from 'lucide-react';
import TaskForm from './TaskForm'; import TaskForm from './TaskForm';
import { apiService } from '../services/api'; import { apiService } from '../services/api';
@ -21,7 +21,7 @@ const TaskPlanner: React.FC<TaskPlannerProps> = ({ tasks, plants, onTasksChange
return true; return true;
}).sort((a, b) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime()); }).sort((a, b) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime());
const handleSaveTask = async (taskData: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>) => { const handleSaveTask = async (taskData: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'fertilizerName' | 'chemicalName'>) => {
try { try {
if (editingTask) { if (editingTask) {
const updatedTask = await apiService.updateTask(editingTask.id, taskData); const updatedTask = await apiService.updateTask(editingTask.id, taskData);
@ -60,6 +60,32 @@ const TaskPlanner: React.FC<TaskPlannerProps> = ({ tasks, plants, onTasksChange
} }
}; };
const getTaskTypeIcon = (type?: string) => {
switch (type) {
case 'fertilizer': return Sprout;
case 'chemical': return Beaker;
case 'watering': return FileText;
case 'pruning': return FileText;
case 'transplanting': return FileText;
case 'harvesting': return FileText;
case 'other': return FileText;
default: return FileText;
}
};
const getTaskTypeColor = (type?: string) => {
switch (type) {
case 'fertilizer': return 'text-green-600';
case 'chemical': return 'text-red-600';
case 'watering': return 'text-blue-600';
case 'pruning': return 'text-purple-600';
case 'transplanting': return 'text-orange-600';
case 'harvesting': return 'text-yellow-600';
case 'other': return 'text-gray-600';
default: return 'text-gray-600';
}
};
const getTaskStatus = (task: Task) => { const getTaskStatus = (task: Task) => {
if (task.completed) return 'completed'; if (task.completed) return 'completed';
if (new Date(task.deadline) < new Date()) return 'overdue'; if (new Date(task.deadline) < new Date()) return 'overdue';
@ -88,15 +114,15 @@ const TaskPlanner: React.FC<TaskPlannerProps> = ({ tasks, plants, onTasksChange
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h2 className="text-3xl font-bold text-green-800">Планирование работ</h2> <h2 className="text-3xl font-bold text-green-800">Task Planner</h2>
<p className="text-green-600 mt-1">Планируйте и отслеживайте свои работы по уходу за садом</p> <p className="text-green-600 mt-1">Plan and track your garden maintenance tasks</p>
</div> </div>
<button <button
onClick={() => setShowTaskForm(true)} 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" 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" /> <Plus className="h-4 w-4" />
<span>Новая работа</span> <span>Add Task</span>
</button> </button>
</div> </div>
@ -105,8 +131,8 @@ const TaskPlanner: React.FC<TaskPlannerProps> = ({ tasks, plants, onTasksChange
<div className="flex border-b border-green-100"> <div className="flex border-b border-green-100">
{[ {[
{ key: 'all', label: 'Все работы', count: tasks.length }, { key: 'all', label: 'Все работы', count: tasks.length },
{ key: 'pending', label: 'Отложено', count: tasks.filter(t => !t.completed).length }, { key: 'pending', label: 'Отложенные', count: tasks.filter(t => !t.completed).length },
{ key: 'completed', label: 'Выполнено', count: tasks.filter(t => t.completed).length } { key: 'completed', label: 'Выполненные', count: tasks.filter(t => t.completed).length }
].map((tab) => ( ].map((tab) => (
<button <button
key={tab.key} key={tab.key}
@ -129,6 +155,7 @@ const TaskPlanner: React.FC<TaskPlannerProps> = ({ tasks, plants, onTasksChange
const plant = plants.find(p => p.id === task.plantId); const plant = plants.find(p => p.id === task.plantId);
const status = getTaskStatus(task); const status = getTaskStatus(task);
const StatusIcon = getStatusIcon(status); const StatusIcon = getStatusIcon(status);
const TaskTypeIcon = getTaskTypeIcon(task.type);
return ( return (
<div <div
@ -152,6 +179,12 @@ const TaskPlanner: React.FC<TaskPlannerProps> = ({ tasks, plants, onTasksChange
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center space-x-3 mb-2"> <div className="flex items-center space-x-3 mb-2">
<div className="flex items-center space-x-2">
<TaskTypeIcon className={`h-4 w-4 ${getTaskTypeColor(task.type)}`} />
<span className={`text-xs font-medium capitalize ${getTaskTypeColor(task.type)}`}>
{TaskTitles[task.type || 'general']}
</span>
</div>
<h3 className={`text-lg font-semibold ${task.completed ? 'line-through text-gray-500' : 'text-gray-900'}`}> <h3 className={`text-lg font-semibold ${task.completed ? 'line-through text-gray-500' : 'text-gray-900'}`}>
{task.title} {task.title}
</h3> </h3>
@ -165,16 +198,27 @@ const TaskPlanner: React.FC<TaskPlannerProps> = ({ tasks, plants, onTasksChange
{plant && ( {plant && (
<p className="text-sm text-gray-600 mb-2"> <p className="text-sm text-gray-600 mb-2">
Растение: <span className="font-medium">{plant.variety}</span> Plant: <span className="font-medium">{plant.variety}</span>
</p> </p>
)} )}
{task.fertilizerName && (
<p className="text-sm text-green-600 mb-2">
Fertilizer: <span className="font-medium">{task.fertilizerName}</span>
</p>
)}
{task.chemicalName && (
<p className="text-sm text-red-600 mb-2">
Chemical: <span className="font-medium">{task.chemicalName}</span>
</p>
)}
<p className="text-gray-700 mb-3">{task.description}</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-4 text-sm text-gray-500">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
<span>Срок: {new Date(task.deadline).toLocaleDateString()}</span> <span>Due: {new Date(task.deadline).toLocaleDateString()}</span>
</div> </div>
{new Date(task.deadline) < new Date() && !task.completed && ( {new Date(task.deadline) < new Date() && !task.completed && (
<span className="text-red-600 font-medium">Overdue</span> <span className="text-red-600 font-medium">Overdue</span>
@ -223,13 +267,13 @@ const TaskPlanner: React.FC<TaskPlannerProps> = ({ tasks, plants, onTasksChange
{/* Task Form Modal */} {/* Task Form Modal */}
{showTaskForm && ( {showTaskForm && (
<TaskForm <TaskForm
task={editingTask} isOpen={showTaskForm}
plants={plants} onClose={() => {
onSave={handleSaveTask}
onCancel={() => {
setShowTaskForm(false); setShowTaskForm(false);
setEditingTask(null); setEditingTask(null);
}} }}
onSubmit={handleSaveTask}
task={editingTask ?? undefined}
/> />
)} )}
</div> </div>

View File

@ -0,0 +1,22 @@
import React from 'react';
// Описание типа для пропсов компонента
interface FruitsWordProps {
fruits: number; // Количество минут
}
const FruitsWord: React.FC<FruitsWordProps> = ({ fruits }) => {
const getFruitWord = (fruits: number): string => {
if (fruits === 1) {
return "плод";
}
if (fruits >= 2 && fruits <= 4) {
return "плода";
}
return "плодов";
};
return <>{getFruitWord(fruits)}</>;
};
export default FruitsWord;

View File

@ -1,4 +1,13 @@
import { Plant, Task, MaintenanceRecord, HarvestRecord, PlantHistory, PlantObservation } from '../types'; import {
Plant,
Task,
MaintenanceRecord,
HarvestRecord,
PlantHistory,
PlantObservation,
Fertilizer,
Chemical
} from '../types';
class ApiService { class ApiService {
private baseUrl: string; private baseUrl: string;
@ -227,6 +236,28 @@ class ApiService {
return this.makeRequest<{ status: string; message: string }>('/health'); return this.makeRequest<{ status: string; message: string }>('/health');
} }
// Photo upload
async uploadPhoto(file: File): Promise<{ photoUrl: string }> {
const formData = new FormData();
formData.append('photo', file);
const url = `${this.baseUrl}/upload-photo`;
try {
const response = await fetch(url, {
method: 'POST',
body: formData,
});
return this.handleResponse<{ photoUrl: string }>(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;
}
}
// Utility methods for batch operations // Utility methods for batch operations
async getPlantWithDetails(plantId: number): Promise<{ async getPlantWithDetails(plantId: number): Promise<{
plant: Plant; plant: Plant;
@ -289,6 +320,57 @@ class ApiService {
}> { }> {
return this.makeRequest<any>('/dashboard/summary'); return this.makeRequest<any>('/dashboard/summary');
} }
// Fertilizer API methods
async getFertilizers(): Promise<Fertilizer[]> {
return this.makeRequest<Fertilizer[]>('/fertilizers');
}
async createFertilizer(fertilizer: Omit<Fertilizer, 'id' | 'createdAt' | 'updatedAt'>): Promise<Fertilizer> {
return this.makeRequest<Fertilizer>('/fertilizers', {
method: 'POST',
body: JSON.stringify(fertilizer),
});
}
async updateFertilizer(id: number, fertilizer: Partial<Fertilizer>): Promise<Fertilizer> {
return this.makeRequest<Fertilizer>(`/fertilizers/${id}`, {
method: 'PUT',
body: JSON.stringify(fertilizer),
});
}
async deleteFertilizer(id: number): Promise<void> {
return this.makeRequest<void>(`/fertilizers/${id}`, {
method: 'DELETE',
});
}
// Chemical API methods
async getChemicals(): Promise<Chemical[]> {
return this.makeRequest<Chemical[]>('/chemicals');
}
async createChemical(chemical: Omit<Chemical, 'id' | 'createdAt' | 'updatedAt'>): Promise<Chemical> {
return this.makeRequest<Chemical>('/chemicals', {
method: 'POST',
body: JSON.stringify(chemical),
});
}
async updateChemical(id: number, chemical: Partial<Chemical>): Promise<Chemical> {
return this.makeRequest<Chemical>(`/chemicals/${id}`, {
method: 'PUT',
body: JSON.stringify(chemical),
});
}
async deleteChemical(id: number): Promise<void> {
return this.makeRequest<void>(`/chemicals/${id}`, {
method: 'DELETE',
});
}
} }
// Create and export a singleton instance // Create and export a singleton instance

View File

@ -1,3 +1,5 @@
import React from "react";
export interface Plant { export interface Plant {
id: number; id: number;
type: string; type: string;
@ -60,19 +62,52 @@ export interface MaintenanceRecord {
type: 'chemical' | 'fertilizer' | 'watering' | 'pruning' | 'transplanting' | 'other'; type: 'chemical' | 'fertilizer' | 'watering' | 'pruning' | 'transplanting' | 'other';
description: string; description: string;
amount?: string; amount?: string;
fertilizerId?: number;
chemicalId?: number;
fertilizerName?: string;
chemicalName?: string;
isPlanned: boolean; isPlanned: boolean;
isCompleted: boolean; isCompleted: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export interface Task { export const taskTypes = [
id: number; 'general',
'fertilizer',
'chemical',
'watering',
'pruning',
'transplanting',
'harvesting',
'other',
] as const;
//export type TaskType = typeof taskTypes[number];
export type TaskDraft = {
plantId?: number; plantId?: number;
type?: TaskType;
title: string; title: string;
description: string; description: string;
deadline: string; deadline: string;
completed: boolean; completed: boolean;
fertilizerId?: number;
chemicalId?: number;
};
export interface Task {
id: number;
plantId?: number;
type?: 'general' | 'fertilizer' | 'chemical' | 'watering' | 'pruning' | 'transplanting' | 'harvesting' | 'other';
title: string;
description: string;
deadline: string;
completed: boolean;
fertilizerId?: number;
chemicalId?: number;
fertilizerName?: string;
chemicalName?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@ -82,6 +117,7 @@ export interface PlantWithHistory extends Plant {
harvests: HarvestRecord[]; harvests: HarvestRecord[];
maintenance: MaintenanceRecord[]; maintenance: MaintenanceRecord[];
} }
export interface PlantStatistics { export interface PlantStatistics {
plantId: number; plantId: number;
year: number; year: number;
@ -99,3 +135,79 @@ export interface PlantStatistics {
completedTasks: number; completedTasks: number;
plannedTasks: number; plannedTasks: number;
} }
export interface Fertilizer {
id: number;
name: string;
brand?: string;
type: 'organic' | 'synthetic' | 'liquid' | 'granular' | 'slow-release';
npkRatio?: string;
description?: string;
applicationRate?: string;
frequency?: string;
season?: string;
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface Chemical {
id: number;
name: string;
brand?: string;
type: 'pesticide' | 'herbicide' | 'fungicide' | 'insecticide' | 'miticide';
activeIngredient?: string;
concentration?: string;
targetPests?: string;
applicationMethod?: string;
safetyPeriod?: number;
notes?: string;
createdAt: string;
updatedAt: string;
}
export const HarvestUnits: Record<string, string> = {
'kg': 'кг',
'pieces': 'плоды',
'bunches': 'пучки',
'cups': 'чашки',
'liters': 'литры'
};
export const PlantTitles: Record<string, string> = {
'tree': 'дерево',
'shrub': 'кустарник',
'herb': 'зелень',
'vegetable': 'овощи',
'flower': 'цветы',
'grass': 'трава',
'other': 'другое'
};
export const Conditions: Record<string, string> = {
'good': 'хорошее',
'needs-attention': 'требует внимания',
'dead': 'погибло'
};
export const Maintenances: Record<string, string> = {
'chemical': 'обработка химикатами',
'fertilizer': 'внесение удобрений',
'watering': 'полив',
'pruning': 'обрезка',
'transplanting': 'пересадка',
'other': 'другое'
};
export type TaskType = NonNullable<Task["type"]>;
export const TaskTitles: Record<TaskType, string> = {
general: 'общая',
fertilizer: 'внесение удобрений',
chemical: 'обработка химикатами',
watering: 'полив',
pruning: 'обрезка',
transplanting: 'пересадка',
harvesting: 'сбор урожая',
other: 'другое'
};