Рабочая версия 0.3.0 Добавлены заготовки, боковое меню, фильтры в журнал наблюдений. Изменен интерфейс загрузки изображений.

This commit is contained in:
anibilag 2025-09-04 23:08:44 +03:00
parent 12d0f3708d
commit 5f83aee336
34 changed files with 1582 additions and 883 deletions

Binary file not shown.

View File

@ -137,6 +137,7 @@ function initializeDatabase() {
) )
`); `);
console.log('Chemicals table ready'); 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 (
@ -156,6 +157,42 @@ function initializeDatabase() {
`); `);
console.log('Plant observations table ready'); console.log('Plant observations table ready');
// Harvest processing table
db.exec(`
CREATE TABLE IF NOT EXISTS harvest_processing (
id INTEGER PRIMARY KEY AUTOINCREMENT,
harvest_id INTEGER NOT NULL,
process_type TEXT NOT NULL CHECK(process_type IN ('fresh', 'frozen', 'jam', 'dried', 'canned', 'juice', 'sauce', 'pickled', 'other')),
quantity REAL NOT NULL,
unit TEXT NOT NULL,
process_date DATE NOT NULL,
expiry_date DATE,
storage_location TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (harvest_id) REFERENCES harvest_records (id) ON DELETE CASCADE
)
`);
console.log('Harvest processing table ready');
// Harvest stock table
db.exec(`
CREATE TABLE IF NOT EXISTS harvest_stock (
id INTEGER PRIMARY KEY AUTOINCREMENT,
processing_id INTEGER NOT NULL,
current_quantity REAL NOT NULL,
unit TEXT NOT NULL,
last_updated DATE NOT NULL,
status TEXT DEFAULT 'available' CHECK(status IN ('available', 'consumed', 'expired', 'spoiled')),
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (processing_id) REFERENCES harvest_processing (id) ON DELETE CASCADE
)
`);
console.log('Harvest stock table ready');
// Plant history table // Plant history table
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS plant_history ( CREATE TABLE IF NOT EXISTS plant_history (
@ -784,7 +821,7 @@ app.post('/api/harvests', (req, res) => {
const result = stmt.run(plantId, date, quantity, unit, notes); const result = stmt.run(plantId, date, quantity, unit, notes);
const getStmt = db.prepare('SELECT * FROM harvest_records WHERE id = ?'); const getStmt = db.prepare('SELECT * FROM harvest_records WHERE id = ? ORDER BY date DESC');
const row = getStmt.get(result.lastInsertRowid); const row = getStmt.get(result.lastInsertRowid);
res.json({ res.json({
@ -802,6 +839,156 @@ app.post('/api/harvests', (req, res) => {
} }
}); });
// Harvest Processing
app.get('/api/harvest-processing', (req, res) => {
try {
const stmt = db.prepare(`
SELECT hp.*, hr.plant_id, hr.date as harvest_date
FROM harvest_processing hp
JOIN harvest_records hr ON hp.harvest_id = hr.id
ORDER BY hp.process_date DESC
`);
const rows = stmt.all();
res.json(rows.map(row => ({
id: row.id,
harvestId: row.harvest_id,
plantId: row.plant_id,
harvestDate: row.harvest_date,
processType: row.process_type,
quantity: row.quantity,
unit: row.unit,
processDate: row.process_date,
expiryDate: row.expiry_date,
storageLocation: row.storage_location,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
})));
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/harvest-processing', (req, res) => {
try {
const { harvestId, processType, quantity, unit, processDate, expiryDate, storageLocation, notes } = req.body;
const stmt = db.prepare(`
INSERT INTO harvest_processing (harvest_id, process_type, quantity, unit, process_date, expiry_date, storage_location, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(harvestId, processType, quantity, unit, processDate, expiryDate, storageLocation, notes);
// Create corresponding stock entry
const stockStmt = db.prepare(`
INSERT INTO harvest_stock (processing_id, current_quantity, unit, last_updated, status)
VALUES (?, ?, ?, ?, 'available')
`);
stockStmt.run(result.lastInsertRowid, quantity, unit, processDate);
const getStmt = db.prepare(`
SELECT hp.*, hr.plant_id, hr.date as harvest_date
FROM harvest_processing hp
JOIN harvest_records hr ON hp.harvest_id = hr.id
WHERE hp.id = ?
`);
const row = getStmt.get(result.lastInsertRowid);
res.json({
id: row.id,
harvestId: row.harvest_id,
plantId: row.plant_id,
harvestDate: row.harvest_date,
processType: row.process_type,
quantity: row.quantity,
unit: row.unit,
processDate: row.process_date,
expiryDate: row.expiry_date,
storageLocation: row.storage_location,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
});
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// Harvest Stock
app.get('/api/harvest-stock', (req, res) => {
try {
const stmt = db.prepare(`
SELECT hs.*, hp.process_type, hp.harvest_id, hr.plant_id, hr.date as harvest_date
FROM harvest_stock hs
JOIN harvest_processing hp ON hs.processing_id = hp.id
JOIN harvest_records hr ON hp.harvest_id = hr.id
ORDER BY hs.last_updated DESC
`);
const rows = stmt.all();
res.json(rows.map(row => ({
id: row.id,
processingId: row.processing_id,
harvestId: row.harvest_id,
plantId: row.plant_id,
harvestDate: row.harvest_date,
processType: row.process_type,
currentQuantity: row.current_quantity,
unit: row.unit,
lastUpdated: row.last_updated,
status: row.status,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
})));
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/harvest-stock/:id', (req, res) => {
try {
const { currentQuantity, status, notes } = req.body;
const stmt = db.prepare(`
UPDATE harvest_stock SET current_quantity = ?, status = ?, notes = ?,
last_updated = date('now'), updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
stmt.run(currentQuantity, status, notes, req.params.id);
const getStmt = db.prepare(`
SELECT hs.*, hp.process_type, hp.harvest_id, hr.plant_id, hr.date as harvest_date
FROM harvest_stock hs
JOIN harvest_processing hp ON hs.processing_id = hp.id
JOIN harvest_records hr ON hp.harvest_id = hr.id
WHERE hs.id = ?
`);
const row = getStmt.get(req.params.id);
res.json({
id: row.id,
processingId: row.processing_id,
harvestId: row.harvest_id,
plantId: row.plant_id,
harvestDate: row.harvest_date,
processType: row.process_type,
currentQuantity: row.current_quantity,
unit: row.unit,
lastUpdated: row.last_updated,
status: row.status,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
});
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// Health check // Health check
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {
res.json({ status: 'OK', message: 'GardenTrack API is running' }); res.json({ status: 'OK', message: 'GardenTrack API is running' });

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

560
package-lock.json generated
View File

@ -1,13 +1,14 @@
{ {
"name": "vite-react-typescript-starter", "name": "vite-react-typescript-starter",
"version": "0.0.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "vite-react-typescript-starter", "name": "vite-react-typescript-starter",
"version": "0.0.0", "version": "0.2.0",
"dependencies": { "dependencies": {
"framer-motion": "^12.23.12",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
@ -18,12 +19,10 @@
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18", "autoprefixer": "^10.4.18",
"concurrently": "^8.2.2",
"eslint": "^9.9.1", "eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11", "eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0", "globals": "^15.9.0",
"nodemon": "^3.0.2",
"postcss": "^8.4.35", "postcss": "^8.4.35",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5.5.3", "typescript": "^5.5.3",
@ -292,16 +291,6 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz",
"integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.25.7", "version": "7.25.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz",
@ -1831,120 +1820,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/cliui/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "1.9.3", "version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@ -1975,126 +1850,6 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true "dev": true
}, },
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"spawn-command": "0.0.2",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"yargs": "^17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": "^14.13.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/concurrently/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/concurrently/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/concurrently/node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/concurrently/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/concurrently/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/concurrently/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -2133,23 +1888,6 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true "dev": true
}, },
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
@ -2652,6 +2390,33 @@
"url": "https://github.com/sponsors/rawify" "url": "https://github.com/sponsors/rawify"
} }
}, },
"node_modules/framer-motion": {
"version": "12.23.12",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.12.tgz",
"integrity": "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.12",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -2684,16 +2449,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/glob": { "node_modules/glob": {
"version": "10.4.5", "version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@ -2798,13 +2553,6 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
"integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
"dev": true,
"license": "ISC"
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -3040,13 +2788,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -3124,6 +2865,21 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/motion-dom": {
"version": "12.23.12",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz",
"integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -3171,48 +2927,6 @@
"integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
"dev": true "dev": true
}, },
"node_modules/nodemon": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
"integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.5.2",
"debug": "^4",
"ignore-by-default": "^1.0.1",
"minimatch": "^3.1.2",
"pstree.remy": "^1.1.8",
"semver": "^7.5.3",
"simple-update-notifier": "^2.0.0",
"supports-color": "^5.5.0",
"touch": "^3.1.0",
"undefsafe": "^2.0.5"
},
"bin": {
"nodemon": "bin/nodemon.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nodemon"
}
},
"node_modules/nodemon/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -3548,13 +3262,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
"dev": true,
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -3637,16 +3344,6 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.8", "version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
@ -3741,16 +3438,6 @@
"queue-microtask": "^1.2.2" "queue-microtask": "^1.2.2"
} }
}, },
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@ -3789,19 +3476,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@ -3814,32 +3488,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-update-notifier/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -3849,12 +3497,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/spawn-command": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -4094,26 +3736,6 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/touch": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
"integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
"dev": true,
"license": "ISC",
"bin": {
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz",
@ -4136,7 +3758,6 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/type-check": { "node_modules/type-check": {
@ -4187,13 +3808,6 @@
} }
} }
}, },
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
@ -4443,16 +4057,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@ -4471,80 +4075,6 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -1,7 +1,7 @@
{ {
"name": "vite-react-typescript-starter", "name": "vite-react-typescript-starter",
"private": true, "private": true,
"version": "0.2.0", "version": "0.3.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --mode development", "dev": "vite --mode development",
@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"framer-motion": "^12.23.12",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import {Sprout, Calendar, CheckSquare, Activity, BookOpen, Beaker, FlaskConical} from 'lucide-react'; import { Sprout, Calendar, CheckSquare, Activity, BookOpen, Beaker, FlaskConical, Archive } 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';
@ -8,6 +8,7 @@ import MaintenanceLog from './components/MaintenanceLog';
import ObservationJournal from './components/ObservationJournal'; import ObservationJournal from './components/ObservationJournal';
import FertilizerRegistry from './components/FertilizerRegistry'; import FertilizerRegistry from './components/FertilizerRegistry';
import ChemicalRegistry from './components/ChemicalRegistry'; import ChemicalRegistry from './components/ChemicalRegistry';
import HarvestStockManager from './components/HarvestStockManager';
import { apiService } from './services/api'; import { apiService } from './services/api';
function App() { function App() {
@ -56,10 +57,11 @@ function App() {
{ id: 'dashboard', label: 'Панель', icon: Activity }, { id: 'dashboard', label: 'Панель', icon: Activity },
{ id: 'plants', label: 'Реестр растений', icon: Sprout }, { id: 'plants', label: 'Реестр растений', icon: Sprout },
{ 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: 'fertilizers', label: 'Удобрения', icon: FlaskConical },
{ id: 'chemicals', label: 'Химикаты', icon: Beaker }, { id: 'chemicals', label: 'Химикаты', icon: Beaker },
{ id: 'stock', label: 'Заготовки', icon: Archive },
]; ];
if (loading) { if (loading) {
@ -74,109 +76,129 @@ function App() {
} }
return ( return (
<div className="min-h-screen bg-green-50"> <div className="min-h-screen bg-green-50 flex">
<header className="bg-white shadow-sm border-b border-green-100"> {/* Left Sidebar */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="hidden md:flex md:w-64 md:flex-col">
<div className="flex justify-between items-center h-16"> <div className="flex flex-col flex-grow bg-white shadow-sm border-r border-green-100">
<div className="flex items-center space-x-3"> {/* Logo */}
<div className="flex items-center space-x-3 px-6 py-4 border-b border-green-100">
<Sprout className="h-8 w-8 text-green-600" /> <Sprout className="h-8 w-8 text-green-600" />
<h1 className="text-2xl font-bold text-green-800">GardenTrack</h1> <h1 className="text-xl font-bold text-green-800">GardenTrack</h1>
</div> </div>
<nav className="hidden md:flex space-x-6">
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-2">
{navItems.map((item) => { {navItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
return ( return (
<button <button
key={item.id} key={item.id}
onClick={() => setActiveTab(item.id)} onClick={() => setActiveTab(item.id)}
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${ className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
activeTab === item.id activeTab === item.id
? 'bg-green-100 text-green-700' ? 'bg-green-100 text-green-700 border border-green-200'
: 'text-gray-600 hover:text-green-600 hover:bg-green-50' : 'text-gray-600 hover:text-green-600 hover:bg-green-50'
}`} }`}
> >
<Icon className="h-4 w-4" /> <Icon className="h-5 w-5" />
<span>{item.label}</span> <span>{item.label}</span>
</button> </button>
); );
})} })}
</nav> </nav>
</div> </div>
</div> </div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> {/* Main Content */}
{activeTab === 'dashboard' && ( <div className="flex-1 flex flex-col">
<Dashboard {/* Mobile Header */}
plants={plants} <header className="md:hidden bg-white shadow-sm border-b border-green-100">
tasks={tasks} <div className="px-4 py-4">
maintenanceRecords={maintenanceRecords} <div className="flex items-center space-x-3">
harvestRecords={harvestRecords} <Sprout className="h-6 w-6 text-green-600" />
onNavigate={setActiveTab} <h1 className="text-lg font-bold text-green-800">GardenTrack</h1>
/> </div>
)} </div>
{activeTab === 'plants' && ( </header>
<PlantRegistry
plants={plants}
onPlantsChange={setPlants}
harvestRecords={harvestRecords}
onHarvestRecordsChange={setHarvestRecords}
maintenanceRecords={maintenanceRecords}
/>
)}
{activeTab === 'tasks' && (
<TaskPlanner
tasks={tasks}
plants={plants}
onTasksChange={setTasks}
/>
)}
{activeTab === 'maintenance' && (
<MaintenanceLog
maintenanceRecords={maintenanceRecords}
plants={plants}
onMaintenanceChange={setMaintenanceRecords}
/>
)}
{activeTab === 'observations' && (
<ObservationJournal
observations={observations}
plants={plants}
onObservationsChange={setObservations}
/>
)}
{activeTab === 'fertilizers' && (
<FertilizerRegistry />
)}
{activeTab === 'chemicals' && (
<ChemicalRegistry />
)}
</main>
{/* Mobile Navigation */} {/* Main Content Area */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-green-100 px-4 py-2"> <main className="flex-1 px-4 sm:px-6 lg:px-8 py-8 overflow-auto">
<div className="flex justify-around"> {activeTab === 'dashboard' && (
{navItems.map((item) => { <Dashboard
const Icon = item.icon; plants={plants}
return ( tasks={tasks}
<button maintenanceRecords={maintenanceRecords}
key={item.id} harvestRecords={harvestRecords}
onClick={() => setActiveTab(item.id)} onNavigate={setActiveTab}
className={`flex flex-col items-center py-2 px-3 rounded-md transition-colors ${ />
activeTab === item.id )}
? 'text-green-600' {activeTab === 'plants' && (
: 'text-gray-400 hover:text-green-600' <PlantRegistry
}`} plants={plants}
> onPlantsChange={setPlants}
<Icon className="h-5 w-5" /> harvestRecords={harvestRecords}
<span className="text-xs mt-1">{item.label.split(' ')[0]}</span> onHarvestRecordsChange={setHarvestRecords}
</button> maintenanceRecords={maintenanceRecords}
); />
})} )}
{activeTab === 'tasks' && (
<TaskPlanner
tasks={tasks}
plants={plants}
onTasksChange={setTasks}
/>
)}
{activeTab === 'maintenance' && (
<MaintenanceLog
maintenanceRecords={maintenanceRecords}
plants={plants}
onMaintenanceChange={setMaintenanceRecords}
/>
)}
{activeTab === 'observations' && (
<ObservationJournal
observations={observations}
plants={plants}
onObservationsChange={setObservations}
/>
)}
{activeTab === 'fertilizers' && (
<FertilizerRegistry />
)}
{activeTab === 'chemicals' && (
<ChemicalRegistry />
)}
{activeTab === 'stock' && (
<HarvestStockManager plants={plants} />
)}
</main>
</div> </div>
</nav>
</div> {/* Mobile Navigation */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-green-100 px-2 py-2">
<div className="flex justify-around">
{navItems.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
className={`flex flex-col items-center py-2 px-2 rounded-md transition-colors ${
activeTab === item.id
? 'text-green-600'
: 'text-gray-400 hover:text-green-600'
}`}
>
<Icon className="h-5 w-5" />
<span className="text-xs mt-1 text-center leading-tight">{item.label.split(' ')[0]}</span>
</button>
);
})}
</div>
</nav>
</div>
); );
} }
export default App; export default App;

View File

@ -231,7 +231,7 @@ const ChemicalRegistry: React.FC = () => {
onClick={() => setShowForm(true)} onClick={() => setShowForm(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"
> >
Add your first chemical Добавте первую запись о химикате
</button> </button>
</div> </div>
)} )}

View File

@ -231,7 +231,7 @@ const FertilizerRegistry: React.FC = () => {
onClick={() => setShowForm(true)} onClick={() => setShowForm(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"
> >
Add your first fertilizer Добавте первую запись о удобрении
</button> </button>
</div> </div>
)} )}

View File

@ -0,0 +1,246 @@
import React, { useState } from 'react';
import { HarvestRecord, Plant, HarvestProcessing } from '../types';
import { X } from 'lucide-react';
import { apiService } from '../services/api';
interface HarvestProcessingModalProps {
isOpen: boolean;
harvest: HarvestRecord;
plant: Plant;
onClose: () => void;
onProcessingAdded: () => void;
}
export const processTypes = [
{ value: 'fresh', label: 'Fresh/Raw', color: 'bg-green-100 text-green-800' },
{ value: 'frozen', label: 'Frozen', color: 'bg-blue-100 text-blue-800' },
{ value: 'jam', label: 'Jam/Preserves', color: 'bg-purple-100 text-purple-800' },
{ value: 'dried', label: 'Dried', color: 'bg-yellow-100 text-yellow-800' },
{ value: 'canned', label: 'Canned', color: 'bg-orange-100 text-orange-800' },
{ value: 'juice', label: 'Juice', color: 'bg-pink-100 text-pink-800' },
{ value: 'sauce', label: 'Sauce', color: 'bg-red-100 text-red-800' },
{ value: 'pickled', label: 'Pickled', color: 'bg-indigo-100 text-indigo-800' },
{ value: 'other', label: 'Other', color: 'bg-gray-100 text-gray-800' }
];
export default function HarvestProcessingModal({
isOpen,
harvest,
plant,
onClose,
onProcessingAdded
}: HarvestProcessingModalProps) {
const [formData, setFormData] = useState({
processType: 'fresh' as HarvestProcessing['processType'],
quantity: harvest.quantity.toString(),
unit: harvest.unit,
processDate: new Date().toISOString().split('T')[0],
expiryDate: '',
storageLocation: '',
notes: ''
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await apiService.createHarvestProcessing({
harvestId: harvest.id,
processType: formData.processType,
quantity: parseFloat(formData.quantity),
unit: formData.unit,
processDate: formData.processDate,
expiryDate: formData.expiryDate || undefined,
storageLocation: formData.storageLocation || undefined,
notes: formData.notes || undefined
});
onProcessingAdded();
onClose();
} catch (error) {
console.error('Error creating harvest processing:', error);
alert('Failed to create harvest processing record');
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
if (name === 'unit') {
// Check if the new unit requires integer values
const countableUnits = ['pieces', 'jars', 'bottles', 'bags'];
const requiresInteger = countableUnits.includes(value);
setFormData(prev => ({
...prev,
[name]: value,
// Convert quantity to integer if switching to a countable unit
quantity: requiresInteger ? Math.floor(parseFloat(prev.quantity) || 0).toString() : prev.quantity
}));
} else {
setFormData(prev => ({ ...prev, [name]: value }));
}
};
if (!isOpen) return null;
// Determine if current unit requires integer values
const countableUnits = ['pieces', 'jars', 'bottles', 'bags'];
const requiresInteger = countableUnits.includes(formData.unit);
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b">
<div>
<h2 className="text-xl font-semibold text-gray-900">Обработка урожая</h2>
<p className="text-sm text-gray-600 mt-1">
{plant.variety} - {harvest.quantity} {harvest.unit} собрано {new Date(harvest.date).toLocaleDateString()}
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Тип заготовки *
</label>
<select
name="processType"
value={formData.processType}
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"
>
{processTypes.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">
Дата обработки *
</label>
<input
type="date"
name="processDate"
value={formData.processDate}
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Количество *
</label>
<input
type="number"
name="quantity"
value={formData.quantity}
onChange={handleChange}
step={requiresInteger ? "1" : "0.1"}
min="0"
max={harvest.quantity}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
/>
{requiresInteger && (
<p className="text-xs text-gray-500 mt-1">
Для этого типа требуются только целые числа
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Единицы
</label>
<select
name="unit"
value={formData.unit}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="kg">Килограммы (кг)</option>
<option value="pieces">Штуки</option>
<option value="jars">Банки</option>
<option value="bottles">Бутылки</option>
<option value="bags">Мешки</option>
<option value="liters">Литры</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Срок годности
</label>
<input
type="date"
name="expiryDate"
value={formData.expiryDate}
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Место хранения
</label>
<input
type="text"
name="storageLocation"
value={formData.storageLocation}
onChange={handleChange}
placeholder="Морозилка, Кладовая, Подвал"
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>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Заметки
</label>
<textarea
name="notes"
value={formData.notes}
onChange={handleChange}
rows={3}
placeholder="Используемая рецептура, способ обработки, характеристики качества..."
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 resize-none"
/>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
>
Отмена
</button>
<button
type="submit"
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
>
Добавить
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,305 @@
import { useState, useEffect } from 'react';
import { Plant } from '../types';
import { Package, Edit, X, Clock, MapPin, AlertTriangle } from 'lucide-react';
import { apiService } from '../services/api';
import { processTypes } from "./HarvestProcessingModal";
interface HarvestStockManagerProps {
plants: Plant[];
}
export default function HarvestStockManager({ plants }: HarvestStockManagerProps) {
const [stocks, setStocks] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [editingStock, setEditingStock] = useState<any | null>(null);
const [filterStatus, setFilterStatus] = useState('all');
const [filterPlant, setFilterPlant] = useState('all');
useEffect(() => {
loadStocks();
}, []);
const loadStocks = async () => {
try {
setLoading(true);
const data = await apiService.getHarvestStock();
setStocks(data);
} catch (error) {
console.error('Error loading harvest stock:', error);
} finally {
setLoading(false);
}
};
const filteredStocks = stocks.filter(stock => {
const matchesStatus = filterStatus === 'all' || stock.status === filterStatus;
const matchesPlant = filterPlant === 'all' || stock.plantId.toString() === filterPlant;
return matchesStatus && matchesPlant;
});
const handleUpdateStock = async (stockId: number, updates: any) => {
try {
const updatedStock = await apiService.updateHarvestStock(stockId, updates);
setStocks(stocks.map(s => s.id === stockId ? updatedStock : s));
setEditingStock(null);
} catch (error) {
console.error('Error updating stock:', error);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'available': return 'bg-green-100 text-green-800';
case 'consumed': return 'bg-gray-100 text-gray-800';
case 'expired': return 'bg-red-100 text-red-800';
case 'spoiled': return 'bg-orange-100 text-orange-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const getProcessTypeColor = (type: string) => {
switch (type) {
case 'fresh': return 'bg-green-100 text-green-800';
case 'frozen': return 'bg-blue-100 text-blue-800';
case 'jam': return 'bg-purple-100 text-purple-800';
case 'dried': return 'bg-yellow-100 text-yellow-800';
case 'canned': return 'bg-orange-100 text-orange-800';
case 'juice': return 'bg-pink-100 text-pink-800';
case 'sauce': return 'bg-red-100 text-red-800';
case 'pickled': return 'bg-indigo-100 text-indigo-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>
<h2 className="text-3xl font-bold text-green-800">Управление запасами</h2>
<p className="text-green-600 mt-1">Отслеживайте свои запасы обработанного урожая</p>
</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">Состояние</label>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="all">Все состояния</option>
<option value="available">Доступно</option>
<option value="consumed">Потреблено</option>
<option value="expired">Истек срок годности</option>
<option value="spoiled">Испорчено</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Растение</label>
<select
value={filterPlant}
onChange={(e) => setFilterPlant(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="all">Все растения</option>
{plants.map(plant => (
<option key={plant.id} value={plant.id}>
{plant.variety}
</option>
))}
</select>
</div>
</div>
</div>
{/* Stock Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredStocks.map((stock) => {
const plant = plants.find(p => p.id === stock.plantId);
const isExpiringSoon = stock.expiryDate &&
new Date(stock.expiryDate) <= new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
return (
<div key={stock.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-blue-100 p-2 rounded-lg">
<Package className="h-5 w-5 text-blue-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">{plant?.variety}</h3>
<p className="text-sm text-gray-600">
Собрано: {new Date(stock.harvestDate).toLocaleDateString()}
</p>
</div>
</div>
<button
onClick={() => setEditingStock(stock)}
className="p-1 text-blue-600 hover:text-blue-700 transition-colors"
>
<Edit className="h-4 w-4" />
</button>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Тип:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getProcessTypeColor(stock.processType)}`}>
{processTypes.find(t => t.value === stock.processType)?.label}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Количество:</span>
<span className="text-sm font-medium text-gray-900">
{stock.currentQuantity} {stock.unit}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Состояние:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(stock.status)}`}>
{stock.status}
</span>
</div>
{stock.storageLocation && (
<div className="flex items-center space-x-2">
<MapPin className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-600">{stock.storageLocation}</span>
</div>
)}
{stock.expiryDate && (
<div className={`flex items-center space-x-2 ${isExpiringSoon ? 'text-red-600' : 'text-gray-600'}`}>
<Clock className="h-4 w-4" />
<span className="text-sm">
Срок годности: {new Date(stock.expiryDate).toLocaleDateString()}
</span>
{isExpiringSoon && <AlertTriangle className="h-4 w-4" />}
</div>
)}
<div className="text-sm text-gray-500">
Обновлено: {new Date(stock.lastUpdated).toLocaleDateString()}
</div>
{stock.notes && (
<div className="border-t pt-3">
<p className="text-sm text-gray-700">{stock.notes}</p>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
{filteredStocks.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">No harvest stock found.</p>
<p className="text-sm text-gray-400 mt-2">Process some harvests to see stock here.</p>
</div>
)}
{/* Edit Stock Modal */}
{editingStock && (
<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 w-full max-w-md">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900">Изменить запасы</h2>
<button
onClick={() => setEditingStock(null)}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
handleUpdateStock(editingStock.id, {
currentQuantity: parseFloat(formData.get('currentQuantity') as string),
status: formData.get('status'),
notes: formData.get('notes') || undefined
});
}} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Текущее количество *
</label>
<input
type="number"
name="currentQuantity"
defaultValue={editingStock.currentQuantity}
step="0.1"
min="0"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Состояние *
</label>
<select
name="status"
defaultValue={editingStock.status}
required
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="available">Доступно</option>
<option value="consumed">Потреблено</option>
<option value="expired">Истек срок годности</option>
<option value="spoiled">Испорчено</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Заметки
</label>
<textarea
name="notes"
defaultValue={editingStock.notes || ''}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 resize-none"
/>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t">
<button
type="button"
onClick={() => setEditingStock(null)}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
>
Отмена
</button>
<button
type="submit"
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
>
Изменить
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,157 @@
import React, { useState, useRef } from 'react';
import { Upload, Image, X } from 'lucide-react';
interface ImageUploadProps {
currentImageUrl?: string;
onImageChange: (imageUrl: string) => void;
onFileSelect: (file: File) => void;
uploading?: boolean;
className?: string;
}
export default function ImageUpload({
currentImageUrl,
onImageChange,
onFileSelect,
uploading = false,
className = ""
}: ImageUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
handleFileSelection(file);
} else {
alert('Please select an image file');
}
}
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFileSelection(file);
}
};
const handleFileSelection = (file: File) => {
setSelectedFile(file);
onFileSelect(file);
// Create preview URL
const url = URL.createObjectURL(file);
setPreviewUrl(url);
};
const handleRemoveImage = () => {
setSelectedFile(null);
setPreviewUrl(null);
onImageChange('');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const displayImageUrl = previewUrl || currentImageUrl;
return (
<div className={`space-y-4 ${className}`}>
{/* Drag and Drop Area */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`
relative border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-all
${isDragOver
? 'border-green-400 bg-green-50'
: 'border-gray-300 hover:border-green-400 hover:bg-green-50'
}
${uploading ? 'opacity-50 pointer-events-none' : ''}
`}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileInputChange}
className="hidden"
/>
{uploading ? (
<div className="flex flex-col items-center space-y-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
<p className="text-sm text-gray-600">Изображение загружается...</p>
</div>
) : (
<div className="flex flex-col items-center space-y-2">
<Upload className="h-8 w-8 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-700">
{isDragOver ? 'Перетащите изображение сюда' : 'Перетащите изображение или нажмите, чтобы выбрать'}
</p>
<p className="text-xs text-gray-500">PNG, JPG, GIF до 5MB</p>
</div>
</div>
)}
</div>
{/* Image Preview */}
{displayImageUrl && (
<div className="relative">
<img
src={displayImageUrl.startsWith('/uploads')
? `${import.meta.env.VITE_API_URL || '/api'}${displayImageUrl}`.replace('/api/uploads', '/uploads')
: displayImageUrl
}
alt="Preview"
className="w-full h-48 object-cover rounded-lg border"
onError={(e) => {
console.log('Image failed to load:', displayImageUrl);
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<button
type="button"
onClick={handleRemoveImage}
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{/* File Info */}
{selectedFile && (
<div className="bg-blue-50 p-3 rounded-lg">
<div className="flex items-center space-x-2">
<Image className="h-4 w-4 text-blue-600" />
<span className="text-sm text-blue-800">
Выбран файл: {selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
</span>
</div>
</div>
)}
</div>
);
}

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Plant, MaintenanceRecord } from '../types'; import { Plant, MaintenanceRecord, Maintenances } from '../types';
import { Plus, Calendar, Search, Scissors, Droplets, Beaker, Sprout } from 'lucide-react'; import { Plus, Calendar, Search, Scissors, Droplets, Beaker, Sprout } from 'lucide-react';
import MaintenanceForm from './MaintenanceForm'; import MaintenanceForm from './MaintenanceForm';
import { apiService } from '../services/api'; import { apiService } from '../services/api';
@ -146,9 +146,9 @@ const MaintenanceLog: React.FC<MaintenanceLogProps> = ({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<h3 className="text-lg font-semibold text-gray-900 capitalize">{record.type}</h3> <h3 className="text-lg font-semibold text-gray-900 capitalize">{Maintenances[record.type]}</h3>
<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('-', ' ')} {Maintenances[record.type]}
</span> </span>
{record.fertilizerName && ( {record.fertilizerName && (
<span className="px-2 py-1 bg-green-200 text-green-800 text-xs rounded-full"> <span className="px-2 py-1 bg-green-200 text-green-800 text-xs rounded-full">

View File

@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import {Plant, PlantObservation, PlantTitles} from '../types'; import {Plant, PlantObservation, PlantTitles} from '../types';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import ImageUpload from './ImageUpload';
import { apiService } from '../services/api';
interface ObservationFormProps { interface ObservationFormProps {
observation?: PlantObservation | null; observation?: PlantObservation | null;
@ -21,6 +23,9 @@ const ObservationForm: React.FC<ObservationFormProps> = ({ observation, plants,
tags: '' tags: ''
}); });
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
useEffect(() => { useEffect(() => {
if (observation) { if (observation) {
setFormData({ setFormData({
@ -36,14 +41,33 @@ const ObservationForm: React.FC<ObservationFormProps> = ({ observation, plants,
} }
}, [observation]); }, [observation]);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
let finalPhotoUrl = formData.photoUrl;
// Upload image if a file is selected
if (selectedFile) {
try {
setUploading(true);
const result = await apiService.uploadPhoto(selectedFile);
finalPhotoUrl = result.photoUrl;
} catch (error) {
console.error('Error uploading photo:', error);
alert('Failed to upload photo. Please try again.');
setUploading(false);
return;
} finally {
setUploading(false);
}
}
onSave({ onSave({
...formData, ...formData,
plantId: parseInt(formData.plantId), plantId: parseInt(formData.plantId),
temperature: formData.temperature ? parseFloat(formData.temperature) : undefined, temperature: formData.temperature ? parseFloat(formData.temperature) : undefined,
weatherConditions: formData.weatherConditions || undefined, weatherConditions: formData.weatherConditions || undefined,
photoUrl: formData.photoUrl || undefined, photoUrl: finalPhotoUrl || undefined,
tags: formData.tags || undefined tags: formData.tags || undefined
}); });
}; };
@ -53,6 +77,14 @@ const ObservationForm: React.FC<ObservationFormProps> = ({ observation, plants,
setFormData(prev => ({ ...prev, [name]: value })); setFormData(prev => ({ ...prev, [name]: value }));
}; };
const handleFileSelect = (file: File) => {
setSelectedFile(file);
};
const handleImageChange = (imageUrl: string) => {
setFormData(prev => ({ ...prev, photoUrl: imageUrl }));
};
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-2xl w-full max-h-[90vh] overflow-y-auto"> <div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
@ -173,17 +205,14 @@ const ObservationForm: React.FC<ObservationFormProps> = ({ observation, plants,
</div> </div>
<div> <div>
<label htmlFor="photoUrl" className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-2">
Фотография Фотография
</label> </label>
<input <ImageUpload
type="url" currentImageUrl={formData.photoUrl}
id="photoUrl" onImageChange={handleImageChange}
name="photoUrl" onFileSelect={handleFileSelect}
value={formData.photoUrl} uploading={uploading}
onChange={handleChange}
placeholder="https://example.com/observation-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>
@ -214,10 +243,11 @@ const ObservationForm: React.FC<ObservationFormProps> = ({ observation, plants,
Отмена Отмена
</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" disabled={uploading}
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
{observation ? 'Сохранить' : 'Добавить'} {uploading ? 'Загружается...' : (observation ? 'Изменить' : 'Добавить')}
</button> </button>
</div> </div>
</form> </form>

View File

@ -3,6 +3,7 @@ import { Plant, PlantObservation } from '../types';
import { Plus, Search, Edit, Trash2, Thermometer, Cloud, Tag } from 'lucide-react'; import { Plus, Search, Edit, Trash2, Thermometer, Cloud, Tag } from 'lucide-react';
import ObservationForm from './ObservationForm'; import ObservationForm from './ObservationForm';
import { apiService } from '../services/api'; import { apiService } from '../services/api';
import { ImagePreview } from "./utils/ImagePreview";
interface ObservationJournalProps { interface ObservationJournalProps {
observations: PlantObservation[]; observations: PlantObservation[];
@ -20,16 +21,23 @@ const ObservationJournal: React.FC<ObservationJournalProps> = ({
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [filterPlant, setFilterPlant] = useState('all'); const [filterPlant, setFilterPlant] = useState('all');
const [filterTag, setFilterTag] = useState('all'); const [filterTag, setFilterTag] = useState('all');
const [filterYear, setFilterYear] = useState('all');
const [filterMonth, setFilterMonth] = useState('all');
const filteredObservations = observations.filter(observation => { const filteredObservations = observations.filter(observation => {
const plant = plants.find(p => p.id === observation.plantId); const plant = plants.find(p => p.id === observation.plantId);
const observationDate = new Date(observation.date);
const matchesSearch = observation.title.toLowerCase().includes(searchTerm.toLowerCase()) || const matchesSearch = observation.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
observation.observation.toLowerCase().includes(searchTerm.toLowerCase()) || observation.observation.toLowerCase().includes(searchTerm.toLowerCase()) ||
(plant?.variety.toLowerCase().includes(searchTerm.toLowerCase())); (plant?.variety.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesPlant = filterPlant === 'all' || observation.plantId.toString() === filterPlant; const matchesPlant = filterPlant === 'all' || observation.plantId.toString() === filterPlant;
const matchesTag = filterTag === 'all' || (observation.tags && observation.tags.includes(filterTag)); const matchesTag = filterTag === 'all' || (observation.tags && observation.tags.includes(filterTag));
return matchesSearch && matchesPlant && matchesTag; const matchesYear = filterYear === 'all' || observationDate.getFullYear().toString() === filterYear;
const matchesMonth = filterMonth === 'all' || (observationDate.getMonth() + 1).toString() === filterMonth;
return matchesSearch && matchesPlant && matchesTag && matchesYear && matchesMonth;
}).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); }).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const allTags = Array.from(new Set( const allTags = Array.from(new Set(
@ -38,6 +46,27 @@ const ObservationJournal: React.FC<ObservationJournalProps> = ({
.flatMap(obs => obs.tags!.split(',').map(tag => tag.trim())) .flatMap(obs => obs.tags!.split(',').map(tag => tag.trim()))
)).sort(); )).sort();
// Get available years from observations
const availableYears = Array.from(new Set(
observations.map(obs => new Date(obs.date).getFullYear())
)).sort((a, b) => b - a);
// Month options
const months = [
{ value: '1', label: 'Январь' },
{ value: '2', label: 'Февраль' },
{ value: '3', label: 'Март' },
{ value: '4', label: 'Апрель' },
{ value: '5', label: 'Май' },
{ value: '6', label: 'Июнь' },
{ value: '7', label: 'Июль' },
{ value: '8', label: 'Август' },
{ value: '9', label: 'Сентябрь' },
{ value: '10', label: 'Октябрь' },
{ value: '11', label: 'Ноябрь' },
{ value: '12', label: 'Декабрь' }
];
const handleSaveObservation = async (observationData: Omit<PlantObservation, 'id' | 'createdAt' | 'updatedAt'>) => { const handleSaveObservation = async (observationData: Omit<PlantObservation, 'id' | 'createdAt' | 'updatedAt'>) => {
try { try {
if (editingObservation) { if (editingObservation) {
@ -87,7 +116,7 @@ const ObservationJournal: React.FC<ObservationJournalProps> = ({
{/* Filters */} {/* Filters */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-green-100"> <div className="bg-white p-6 rounded-lg shadow-sm border border-green-100">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">Поиск</label> <label className="block text-sm font-medium text-gray-700 mb-2">Поиск</label>
<div className="relative"> <div className="relative">
@ -101,7 +130,39 @@ const ObservationJournal: React.FC<ObservationJournalProps> = ({
/> />
</div> </div>
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Год</label>
<select
value={filterYear}
onChange={(e) => setFilterYear(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">Все</option>
{availableYears.map(year => (
<option key={year} value={year.toString()}>
{year}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Месяц</label>
<select
value={filterMonth}
onChange={(e) => setFilterMonth(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">Все</option>
{months.map(month => (
<option key={month.value} value={month.value}>
{month.label}
</option>
))}
</select>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">Растения</label> <label className="block text-sm font-medium text-gray-700 mb-2">Растения</label>
<select <select
@ -142,101 +203,118 @@ const ObservationJournal: React.FC<ObservationJournalProps> = ({
const plant = plants.find(p => p.id === observation.plantId); const plant = plants.find(p => p.id === observation.plantId);
return ( return (
<div key={observation.id} className="bg-white rounded-lg shadow-sm border border-green-100 p-6 hover:shadow-md transition-shadow"> <div
<div className="flex items-start justify-between mb-4"> key={observation.id}
<div className="flex-1"> className="bg-white rounded-lg shadow-sm border border-green-100 p-6 hover:shadow-md transition-shadow"
<div className="flex items-center space-x-3 mb-2"> >
<h3 className="text-lg font-semibold text-gray-900">{observation.title}</h3> <div className="flex items-start">
<span className="text-sm text-gray-500"> {/* Левая часть — картинка */}
{new Date(observation.date).toLocaleDateString()}
</span>
</div>
{plant && (
<p className="text-sm text-gray-600 mb-3">
Plant: <span className="font-medium">{plant.variety}</span>
</p>
)}
<p className="text-gray-700 mb-4 leading-relaxed">{observation.observation}</p>
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600">
{observation.weatherConditions && (
<div className="flex items-center space-x-1">
<Cloud className="h-4 w-4" />
<span>{observation.weatherConditions}</span>
</div>
)}
{observation.temperature && (
<div className="flex items-center space-x-1">
<Thermometer className="h-4 w-4" />
<span>{observation.temperature}°C</span>
</div>
)}
</div>
{observation.tags && (
<div className="flex items-center space-x-2 mt-3">
<Tag className="h-4 w-4 text-gray-400" />
<div className="flex flex-wrap gap-1">
{formatTags(observation.tags).map((tag, index) => (
<span
key={index}
className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
</div>
)}
{observation.photoUrl && ( {observation.photoUrl && (
<div className="mt-4"> <div className="w-40 h-40 flex-shrink-0 mr-6">
<img <ImagePreview
src={observation.photoUrl} thumbnailSrc={observation.photoUrl.startsWith('/uploads')
alt={observation.title} ? `${import.meta.env.VITE_API_URL || '/api'}${observation.photoUrl}`.replace('/api/uploads', '/uploads')
className="w-full max-w-md h-48 object-cover rounded-lg" : observation.photoUrl
onError={(e) => { }
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
</div>
<div className="flex space-x-2 ml-4"> fullSrc={observation.photoUrl.startsWith('/uploads')
<button ? `${import.meta.env.VITE_API_URL || '/api'}${observation.photoUrl}`.replace('/api/uploads', '/uploads')
onClick={() => { : observation.photoUrl
setEditingObservation(observation); }
setShowObservationForm(true);
}} alt={observation.title}
className="p-1 text-blue-600 hover:text-blue-700 transition-colors" className="w-32 h-32 object-cover cursor-pointer rounded shadow"
> />
<Edit className="h-4 w-4" /> </div>
</button> )}
<button
onClick={() => handleDeleteObservation(observation.id)} {/* Правая часть — текст */}
className="p-1 text-red-600 hover:text-red-700 transition-colors" <div className="flex-1">
> <div className="flex items-center justify-between mb-2">
<Trash2 className="h-4 w-4" /> <h3 className="text-lg font-semibold text-gray-900">{observation.title}</h3>
</button> <span className="text-sm text-gray-500">
{new Date(observation.date).toLocaleDateString()}
</span>
</div>
{plant && (
<p className="text-sm text-gray-600 mb-3">
Растение: <span className="font-medium">{plant.variety}</span>
</p>
)}
<p className="text-gray-700 mb-4 leading-relaxed">{observation.observation}</p>
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600">
{observation.weatherConditions && (
<div className="flex items-center space-x-1">
<Cloud className="h-4 w-4" />
<span>{observation.weatherConditions}</span>
</div>
)}
{observation.temperature && (
<div className="flex items-center space-x-1">
<Thermometer className="h-4 w-4" />
<span>{observation.temperature}°C</span>
</div>
)}
</div>
{observation.tags && (
<div className="flex items-center space-x-2 mt-3">
<Tag className="h-4 w-4 text-gray-400" />
<div className="flex flex-wrap gap-1">
{formatTags(observation.tags).map((tag, index) => (
<span
key={index}
className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
</div>
)}
</div>
{/* Кнопки справа */}
<div className="flex space-x-2 ml-4">
<button
onClick={() => {
setEditingObservation(observation);
setShowObservationForm(true);
}}
className="p-1 text-blue-600 hover:text-blue-700 transition-colors"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteObservation(observation.id)}
className="p-1 text-red-600 hover:text-red-700 transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div> </div>
</div> </div>
</div>
); );
})} })}
</div> </div>
{filteredObservations.length === 0 && ( {filteredObservations.length === 0 && (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500 text-lg">Записи о наблюдениях не найдены.</p> <p className="text-gray-500 text-lg">
{searchTerm || filterYear !== 'all' || filterMonth !== 'all' || filterPlant !== 'all' || filterTag !== 'all'
? 'No observations found matching your filters.'
: 'No observations found.'
}
</p>
<button <button
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"
> >
Добавить первое наблюдение {observations.length === 0 ? 'Добавить первое наблюдение →' : 'Добавить новое наблюдение →'}
</button> </button>
</div> </div>
)} )}

View File

@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Plant } from '../types'; import { Plant } from '../types';
import { X, Upload, Image } from 'lucide-react'; import { X } from 'lucide-react';
import { apiService } from '../services/api'; import { apiService } from '../services/api';
import ImageUpload from './ImageUpload';
interface PlantFormProps { interface PlantFormProps {
plant?: Plant | null; plant?: Plant | null;
@ -42,14 +43,33 @@ const PlantForm: React.FC<PlantFormProps> = ({ plant, onSave, onCancel }) => {
} }
}, [plant]); }, [plant]);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
let finalPhotoUrl = formData.photoUrl;
// Upload image if a file is selected
if (selectedFile) {
try {
setUploading(true);
const result = await apiService.uploadPhoto(selectedFile);
finalPhotoUrl = result.photoUrl;
} catch (error) {
console.error('Error uploading photo:', error);
alert('Failed to upload photo. Please try again.');
setUploading(false);
return;
} finally {
setUploading(false);
}
}
onSave({ onSave({
...formData, ...formData,
seedlingAge: parseInt(formData.seedlingAge), seedlingAge: parseInt(formData.seedlingAge),
seedlingHeight: parseFloat(formData.seedlingHeight), seedlingHeight: parseFloat(formData.seedlingHeight),
currentHeight: formData.currentHeight ? parseFloat(formData.currentHeight) : undefined, currentHeight: formData.currentHeight ? parseFloat(formData.currentHeight) : undefined,
photoUrl: formData.photoUrl || undefined photoUrl: finalPhotoUrl || undefined
}); });
}; };
@ -58,27 +78,12 @@ 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 handleFileSelect = (file: File) => {
const file = e.target.files?.[0]; setSelectedFile(file);
if (file) {
setSelectedFile(file);
}
}; };
const handleUploadPhoto = async () => { const handleImageChange = (imageUrl: string) => {
if (!selectedFile) return; setFormData(prev => ({ ...prev, photoUrl: imageUrl }));
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 (
@ -239,88 +244,16 @@ const PlantForm: React.FC<PlantFormProps> = ({ plant, onSave, onCancel }) => {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div>
<div> <label className="block text-sm font-medium text-gray-700 mb-2">
<label htmlFor="photoUrl" className="block text-sm font-medium text-gray-700 mb-1"> Plant Photo
Фотография </label>
</label> <ImageUpload
currentImageUrl={formData.photoUrl}
{/* Photo Upload Section */} onImageChange={handleImageChange}
<div className="space-y-3"> onFileSelect={handleFileSelect}
<div className="flex items-center space-x-3"> uploading={uploading}
<input />
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>
@ -347,10 +280,11 @@ const PlantForm: React.FC<PlantFormProps> = ({ plant, onSave, onCancel }) => {
Отмена Отмена
</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" disabled={uploading}
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
{plant ? 'Сохранить' : 'Добавить'} {uploading ? 'Загружается...' : (plant ? 'Изменить' : 'Добавить')}
</button> </button>
</div> </div>
</form> </form>

View File

@ -1,10 +1,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import {Plant, HarvestRecord, PlantTitles, Conditions, HarvestUnits} from '../types'; import {Plant, HarvestRecord, PlantTitles, Conditions, HarvestUnits} from '../types';
import { Plus, Search, Edit, Trash2, Calendar, Award, BarChart3, Grid3X3, List } from 'lucide-react'; import { Plus, Search, Edit, Trash2, Calendar, Award, BarChart3, Grid3X3, List, Package } 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';
import { apiService } from '../services/api'; import { apiService } from '../services/api';
import HarvestProcessingModal from './HarvestProcessingModal';
import { ImagePreview } from "./utils/ImagePreview.tsx";
interface PlantRegistryProps { interface PlantRegistryProps {
plants: Plant[]; plants: Plant[];
@ -14,6 +16,9 @@ interface PlantRegistryProps {
maintenanceRecords: any[]; maintenanceRecords: any[];
} }
//const APP_MODE = import.meta.env.VITE_ENV;
//const API_BASE = APP_MODE == "development" ? import.meta.env.VITE_API_URL : "";
const PlantRegistry: React.FC<PlantRegistryProps> = ({ const PlantRegistry: React.FC<PlantRegistryProps> = ({
plants, plants,
onPlantsChange, onPlantsChange,
@ -27,6 +32,8 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
const [editingPlant, setEditingPlant] = useState<Plant | null>(null); const [editingPlant, setEditingPlant] = useState<Plant | null>(null);
const [selectedPlantForHarvest, setSelectedPlantForHarvest] = useState<Plant | null>(null); const [selectedPlantForHarvest, setSelectedPlantForHarvest] = useState<Plant | null>(null);
const [selectedPlantForStats, setSelectedPlantForStats] = useState<Plant | null>(null); const [selectedPlantForStats, setSelectedPlantForStats] = useState<Plant | null>(null);
const [showProcessingModal, setShowProcessingModal] = useState(false);
const [selectedHarvestForProcessing, setSelectedHarvestForProcessing] = useState<HarvestRecord | null>(null);
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');
@ -210,7 +217,7 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
setShowStatistics(true); setShowStatistics(true);
}} }}
className="p-1 text-purple-600 hover:text-purple-700 transition-colors" className="p-1 text-purple-600 hover:text-purple-700 transition-colors"
title="Посмотреть Статистику" title="Статистика"
> >
<BarChart3 className="h-4 w-4" /> <BarChart3 className="h-4 w-4" />
</button> </button>
@ -220,10 +227,25 @@ 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="Добавить урожай" title="Урожай"
> >
<Award className="h-4 w-4" /> <Award className="h-4 w-4" />
</button> </button>
<button
onClick={() => {
const plantHarvests = getPlantHarvests(plant.id);
if (plantHarvests.length > 0) {
setSelectedHarvestForProcessing(plantHarvests[0]);
setShowProcessingModal(true);
} else {
alert('Урожай не доступен для обработки. Сначала добавьте урожай.');
}
}}
className="p-1 text-orange-600 hover:text-orange-700 transition-colors"
title="Process Harvest"
>
<Package className="h-4 w-4" />
</button>
<button <button
onClick={() => { onClick={() => {
setEditingPlant(plant); setEditingPlant(plant);
@ -245,16 +267,19 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
<div className="space-y-2"> <div className="space-y-2">
{plant.photoUrl && ( {plant.photoUrl && (
<div className="mb-3"> <div className="mb-3">
<img <ImagePreview
src={plant.photoUrl.startsWith('/uploads') thumbnailSrc={plant.photoUrl.startsWith('/uploads')
? `${import.meta.env.VITE_API_URL || '/api'}${plant.photoUrl}`.replace('/api/uploads', '/uploads') ? `${import.meta.env.VITE_API_URL || '/api'}${plant.photoUrl}`.replace('/api/uploads', '/uploads')
: plant.photoUrl : plant.photoUrl
} }
alt={plant.variety}
className="w-full h-32 object-cover rounded-lg" fullSrc={plant.photoUrl.startsWith('/uploads')
onError={(e) => { ? `${import.meta.env.VITE_API_URL || '/api'}${plant.photoUrl}`.replace('/api/uploads', '/uploads')
(e.target as HTMLImageElement).style.display = 'none'; : plant.photoUrl
}} }
alt={plant.variety}
className="w-32 h-32 object-cover cursor-pointer rounded shadow"
/> />
</div> </div>
)} )}
@ -323,25 +348,25 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Plant Растение
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type Тип
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Planted Посажено
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Height Высота
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status Состояние
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Harvest Последний урожай
</th> </th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions Действия
</th> </th>
</tr> </tr>
</thead> </thead>
@ -358,16 +383,19 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
<div className="flex items-center"> <div className="flex items-center">
{plant.photoUrl && ( {plant.photoUrl && (
<div className="flex-shrink-0 h-10 w-10 mr-3"> <div className="flex-shrink-0 h-10 w-10 mr-3">
<img <ImagePreview
src={plant.photoUrl.startsWith('/uploads') thumbnailSrc={plant.photoUrl.startsWith('/uploads')
? `${import.meta.env.VITE_API_URL || '/api'}${plant.photoUrl}`.replace('/api/uploads', '/uploads') ? `${import.meta.env.VITE_API_URL || '/api'}${plant.photoUrl}`.replace('/api/uploads', '/uploads')
: plant.photoUrl : plant.photoUrl
} }
fullSrc={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="h-10 w-10 rounded-full object-cover" className="h-10 w-10 rounded-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/> />
</div> </div>
)} )}
@ -435,6 +463,21 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
> >
<Award className="h-4 w-4" /> <Award className="h-4 w-4" />
</button> </button>
<button
onClick={() => {
const plantHarvests = getPlantHarvests(plant.id);
if (plantHarvests.length > 0) {
setSelectedHarvestForProcessing(plantHarvests[0]);
setShowProcessingModal(true);
} else {
alert('Урожай не доступен для обработки. Сначала добавьте урожай.');
}
}}
className="text-orange-600 hover:text-orange-700 transition-colors"
title="Process Harvest"
>
<Package className="h-4 w-4" />
</button>
<button <button
onClick={() => { onClick={() => {
setEditingPlant(plant); setEditingPlant(plant);
@ -509,6 +552,22 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
}} }}
/> />
)} )}
{/* Harvest Processing Modal */}
{showProcessingModal && selectedHarvestForProcessing && (
<HarvestProcessingModal
isOpen={showProcessingModal}
harvest={selectedHarvestForProcessing}
plant={plants.find(p => p.id === selectedHarvestForProcessing.plantId)!}
onClose={() => {
setShowProcessingModal(false);
setSelectedHarvestForProcessing(null);
}}
onProcessingAdded={() => {
// Refresh data if needed
}}
/>
)}
</div> </div>
); );
}; };

View File

@ -179,30 +179,70 @@ const PlantStatisticsComponent: React.FC<PlantStatisticsProps> = ({
</div> </div>
{/* Harvest Timeline */} {/* Harvest Timeline */}
{statistics.harvestDates.length > 0 && ( {harvestRecords.some(h => h.plantId === plant.id && new Date(h.date).getFullYear() === selectedYear) && (
<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">График сбора урожая</h4> <h4 className="text-lg font-semibold text-gray-900 mb-4">График сбора урожая</h4>
<div className="space-y-2"> <div className="space-y-4">
{statistics.harvestDates.map((date, index) => { {(() => {
const harvest = harvestRecords.find(h => h.date === date && h.plantId === plant.id); // 1. Берём только урожай этого растения за выбранный год
return ( const plantHarvests = harvestRecords.filter(
<div key={index} className="py-3 px-4 bg-white rounded-lg border border-green-100"> h => h.plantId === plant.id && new Date(h.date).getFullYear() === selectedYear
<div className="flex justify-between items-start mb-2">
<span className="text-sm font-medium text-gray-900">
{new Date(date).toLocaleDateString()}
</span>
<span className="text-sm font-medium text-green-600">
{harvest?.quantity} {HarvestUnits[harvest?.unit || '']}
</span>
</div>
{harvest?.notes && (
<p className="text-sm text-gray-600 mt-1">
{harvest.notes}
</p>
)}
</div>
); );
})}
// 2. Группируем по дате
const groupedByDate = plantHarvests.reduce<Record<string, HarvestRecord[]>>((acc, harvest) => {
if (!acc[harvest.date]) {
acc[harvest.date] = [];
}
acc[harvest.date].push(harvest);
return acc;
}, {});
// 3. Сортируем даты:
// если даты разные — по дате (новее выше),
// если совпадают — по id (новее выше)
const sortedDates = Object.keys(groupedByDate).sort((a, b) => {
const dateA = new Date(a).getTime();
const dateB = new Date(b).getTime();
if (dateA !== dateB) {
return dateB - dateA;
}
// если даты одинаковые, сравним максимальный id внутри группы
const maxIdA = Math.max(...groupedByDate[a].map(h => h.id));
const maxIdB = Math.max(...groupedByDate[b].map(h => h.id));
return maxIdB - maxIdA;
});
// 4. Рендерим
return sortedDates.map(date => (
<div key={date} className="bg-white rounded-lg border border-green-100 p-4">
<h5 className="text-md font-medium text-gray-900 mb-2">
{new Date(date).toLocaleDateString()}
</h5>
<div className="space-y-2">
{groupedByDate[date]
.sort((a, b) => b.id - a.id) // внутри даты тоже сортируем по id
.map(harvest => (
<div
key={harvest.id}
className="py-2 px-3 bg-green-50 rounded border border-green-100"
>
<div className="flex justify-between items-start">
<span className="text-sm font-medium text-green-700">
{harvest.quantity} {HarvestUnits[harvest.unit || ""]}
</span>
</div>
{harvest.notes && (
<p className="text-sm text-gray-600 mt-1">{harvest.notes}</p>
)}
</div>
))}
</div>
</div>
));
})()}
</div> </div>
</div> </div>
)} )}

View File

@ -136,7 +136,7 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl 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 items-center justify-between p-6 border-b"> <div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900"> <h2 className="text-xl font-semibold text-gray-900">
{task ? 'Edit Task' : 'Create New Task'} {task ? 'Изменение работы' : 'Создание новой работы'}
</h2> </h2>
<button <button
onClick={onClose} onClick={onClose}
@ -150,7 +150,7 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Task Title * Наименование работы *
</label> </label>
<input <input
type="text" type="text"
@ -164,7 +164,7 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Type * Тип *
</label> </label>
<select <select
required required
@ -182,7 +182,7 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Deadline * Крайний срок *
</label> </label>
<input <input
type="date" type="date"
@ -195,7 +195,7 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Status Статус
</label> </label>
<div className="flex items-center"> <div className="flex items-center">
<input <input
@ -206,14 +206,14 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
className="h-4 w-4 text-green-600 focus:ring-green-500 border-gray-300 rounded" 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 htmlFor="completed" className="ml-2 block text-sm text-gray-700">
Completed Завершена
</label> </label>
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Plant (Optional) Растение (не обязательно)
</label> </label>
<select <select
value={formData.plantId} value={formData.plantId}
@ -256,7 +256,7 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
<p><strong>Application Rate:</strong> {selectedFertilizer.applicationRate}</p> <p><strong>Application Rate:</strong> {selectedFertilizer.applicationRate}</p>
<p><strong>Frequency:</strong> {selectedFertilizer.frequency}</p> <p><strong>Frequency:</strong> {selectedFertilizer.frequency}</p>
{selectedFertilizer.notes && ( {selectedFertilizer.notes && (
<p><strong>Notes:</strong> {selectedFertilizer.notes}</p> <p><strong>Заметки:</strong> {selectedFertilizer.notes}</p>
)} )}
</div> </div>
</div> </div>
@ -295,7 +295,7 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
</p> </p>
)} )}
{selectedChemical.notes && ( {selectedChemical.notes && (
<p><strong>Notes:</strong> {selectedChemical.notes}</p> <p><strong>Заметки:</strong> {selectedChemical.notes}</p>
)} )}
</div> </div>
</div> </div>
@ -305,7 +305,7 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Description Описание
</label> </label>
<textarea <textarea
value={formData.description} value={formData.description}
@ -322,13 +322,13 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 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 text-white rounded-md hover:bg-green-700 transition-colors" className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
> >
{task ? 'Update Task' : 'Create Task'} {task ? 'Изменить' : 'Создать'}
</button> </button>
</div> </div>
</form> </form>

View File

@ -114,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">Task Planner</h2> <h2 className="text-3xl font-bold text-green-800">План работ</h2>
<p className="text-green-600 mt-1">Plan and track your garden maintenance tasks</p> <p className="text-green-600 mt-1">Планируйте и отслеживайте свои задачи по уходу за садом</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>Add Task</span> <span>Новая работа</span>
</button> </button>
</div> </div>
@ -198,7 +198,7 @@ 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">
Plant: <span className="font-medium">{plant.variety}</span> Растение: <span className="font-medium">{plant.variety}</span>
</p> </p>
)} )}

View File

@ -0,0 +1,53 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
interface ImagePreviewProps {
thumbnailSrc: string;
fullSrc: string;
alt?: string;
className?: string;
}
export const ImagePreview: React.FC<ImagePreviewProps> = ({ thumbnailSrc, fullSrc, alt, className }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
{/* Миниатюра */}
<motion.img
src={thumbnailSrc}
alt={alt || ''}
className={className}
whileHover={{ scale: 1.05 }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
onClick={() => setIsOpen(true)}
/>
{/* Модалка */}
<AnimatePresence>
{isOpen && (
<motion.div
className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50"
onClick={() => setIsOpen(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.img
src={fullSrc}
alt={alt || ''}
className="max-w-[90%] max-h-[90%] rounded shadow-lg"
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.5, opacity: 0 }}
transition={{ duration: 0.3 }}
onClick={(e) => e.stopPropagation()}
/>
</motion.div>
)}
</AnimatePresence>
</>
);
};

View File

@ -203,7 +203,9 @@ class ApiService {
// Plant Observations API methods // Plant Observations API methods
async getPlantObservations(): Promise<PlantObservation[]> { async getPlantObservations(): Promise<PlantObservation[]> {
return this.makeRequest<PlantObservation[]>('/observations'); const obs = this.makeRequest<PlantObservation[]>('/observations');
console.log(obs);
return obs;
} }
async createPlantObservation( async createPlantObservation(
@ -371,6 +373,30 @@ class ApiService {
}); });
} }
// Harvest Processing API methods
async getHarvestProcessing(): Promise<any[]> {
return this.makeRequest<any[]>('/harvest-processing');
}
async createHarvestProcessing(processing: any): Promise<any> {
return this.makeRequest<any>('/harvest-processing', {
method: 'POST',
body: JSON.stringify(processing),
});
}
// Harvest Stock API methods
async getHarvestStock(): Promise<any[]> {
return this.makeRequest<any[]>('/harvest-stock');
}
async updateHarvestStock(id: number, stock: any): Promise<any> {
return this.makeRequest<any>(`/harvest-stock/${id}`, {
method: 'PUT',
body: JSON.stringify(stock),
});
}
} }
// Create and export a singleton instance // Create and export a singleton instance

1
src/stub-empty.js Normal file
View File

@ -0,0 +1 @@
export default {};

View File

@ -1,4 +1,3 @@
import React from "react";
export interface Plant { export interface Plant {
id: number; id: number;
@ -55,6 +54,32 @@ export interface HarvestRecord {
updatedAt: string; updatedAt: string;
} }
export interface HarvestProcessing {
id: number;
harvestId: number;
processType: 'fresh' | 'frozen' | 'jam' | 'dried' | 'canned' | 'juice' | 'sauce' | 'pickled' | 'other';
quantity: number;
unit: string;
processDate: string;
expiryDate?: string;
storageLocation?: string;
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface HarvestStock {
id: number;
processingId: number;
currentQuantity: number;
unit: string;
lastUpdated: string;
status: 'available' | 'consumed' | 'expired' | 'spoiled';
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface MaintenanceRecord { export interface MaintenanceRecord {
id: number; id: number;
plantId: number; plantId: number;

View File

@ -7,4 +7,9 @@ export default defineConfig({
optimizeDeps: { optimizeDeps: {
exclude: ['lucide-react'], exclude: ['lucide-react'],
}, },
resolve: {
alias: {
'lucide-react/dist/esm/icons/fingerprint.js': '/src/stub-empty.js'
}
},
}); });