Рабочая версия 0.3.0 Добавлены заготовки, боковое меню, фильтры в журнал наблюдений. Изменен интерфейс загрузки изображений.
@ -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' });
|
||||||
|
BIN
backend/uploads/photo_5247041613312685274_y.jpg
Normal file
After Width: | Height: | Size: 390 KiB |
BIN
backend/uploads/photo_5247041613312685275_y.jpg
Normal file
After Width: | Height: | Size: 282 KiB |
BIN
backend/uploads/photo_5271893247570083300_y.jpg
Normal file
After Width: | Height: | Size: 282 KiB |
BIN
backend/uploads/plant-1753960452405-724160168.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
backend/uploads/plant-1753994802271-447475482.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
backend/uploads/plant-1754921088782-970283539.png
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
backend/uploads/plant-1756392282774-773870385.jpg
Normal file
After Width: | Height: | Size: 339 KiB |
BIN
backend/uploads/plant-1756392409168-924448707.jpg
Normal file
After Width: | Height: | Size: 365 KiB |
BIN
backend/uploads/plant-1756400176200-257499030.png
Normal file
After Width: | Height: | Size: 46 KiB |
BIN
backend/uploads/plant-1756401137797-166836683.png
Normal file
After Width: | Height: | Size: 46 KiB |
BIN
backend/uploads/plant-1756927577450-332932988.jpg
Normal file
After Width: | Height: | Size: 352 KiB |
560
package-lock.json
generated
@ -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",
|
||||||
|
@ -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"
|
||||||
|
204
src/App.tsx
@ -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;
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
246
src/components/HarvestProcessingModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
305
src/components/HarvestStockManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
157
src/components/ImageUpload.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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">
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
53
src/components/utils/ImagePreview.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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
@ -0,0 +1 @@
|
|||||||
|
export default {};
|
@ -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;
|
||||||
|
@ -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'
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|