Рабочая версия 0.2.0 Добавлена статистика, вид растений по карточкам и таблицей.
This commit is contained in:
parent
fee1e116d2
commit
12d0f3708d
Binary file not shown.
143
backend/package-lock.json
generated
143
backend/package-lock.json
generated
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
|
@ -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(`
|
||||||
|
@ -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
|
||||||
|
@ -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",
|
||||||
|
14
src/App.tsx
14
src/App.tsx
@ -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 */}
|
||||||
|
244
src/components/ChemicalForm.tsx
Normal file
244
src/components/ChemicalForm.tsx
Normal 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;
|
254
src/components/ChemicalRegistry.tsx
Normal file
254
src/components/ChemicalRegistry.tsx
Normal 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;
|
@ -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">
|
||||||
|
243
src/components/FertilizerForm.tsx
Normal file
243
src/components/FertilizerForm.tsx
Normal 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;
|
254
src/components/FertilizerRegistry.tsx
Normal file
254
src/components/FertilizerRegistry.tsx
Normal 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;
|
@ -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>
|
||||||
|
@ -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">
|
||||||
Количество (необязательно)
|
Количество (необязательно)
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -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;
|
|
@ -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>
|
||||||
|
22
src/components/words/FruitsWord.tsx
Normal file
22
src/components/words/FruitsWord.tsx
Normal 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;
|
@ -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
|
||||||
|
@ -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: 'другое'
|
||||||
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user