Рабочая версия 0.3.0 Добавлены заготовки, боковое меню, фильтры в журнал наблюдений. Изменен интерфейс загрузки изображений.
@ -137,6 +137,7 @@ function initializeDatabase() {
|
||||
)
|
||||
`);
|
||||
console.log('Chemicals table ready');
|
||||
|
||||
// Plant observations table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS plant_observations (
|
||||
@ -156,6 +157,42 @@ function initializeDatabase() {
|
||||
`);
|
||||
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
|
||||
db.exec(`
|
||||
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 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);
|
||||
|
||||
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
|
||||
app.get('/api/health', (req, res) => {
|
||||
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",
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vite-react-typescript-starter",
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.23.12",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
@ -18,12 +19,10 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"globals": "^15.9.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.5.3",
|
||||
@ -292,16 +291,6 @@
|
||||
"@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": {
|
||||
"version": "7.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz",
|
||||
@ -1831,120 +1820,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": {
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
@ -1975,126 +1850,6 @@
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@ -2133,23 +1888,6 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"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": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
@ -2652,6 +2390,33 @@
|
||||
"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": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@ -2684,16 +2449,6 @@
|
||||
"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": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
@ -2798,13 +2553,6 @@
|
||||
"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": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||
@ -3040,13 +2788,6 @@
|
||||
"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": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@ -3124,6 +2865,21 @@
|
||||
"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": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@ -3171,48 +2927,6 @@
|
||||
"integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@ -3548,13 +3262,6 @@
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@ -3637,16 +3344,6 @@
|
||||
"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": {
|
||||
"version": "1.22.8",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
|
||||
@ -3741,16 +3438,6 @@
|
||||
"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": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
@ -3789,19 +3476,6 @@
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
@ -3814,32 +3488,6 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@ -3849,12 +3497,6 @@
|
||||
"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": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
@ -4094,26 +3736,6 @@
|
||||
"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": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz",
|
||||
@ -4136,7 +3758,6 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
@ -4471,80 +4075,6 @@
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "vite-react-typescript-starter",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --mode development",
|
||||
@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.23.12",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
204
src/App.tsx
@ -1,5 +1,5 @@
|
||||
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 Dashboard from './components/Dashboard';
|
||||
import PlantRegistry from './components/PlantRegistry';
|
||||
@ -8,6 +8,7 @@ import MaintenanceLog from './components/MaintenanceLog';
|
||||
import ObservationJournal from './components/ObservationJournal';
|
||||
import FertilizerRegistry from './components/FertilizerRegistry';
|
||||
import ChemicalRegistry from './components/ChemicalRegistry';
|
||||
import HarvestStockManager from './components/HarvestStockManager';
|
||||
import { apiService } from './services/api';
|
||||
|
||||
function App() {
|
||||
@ -56,10 +57,11 @@ function App() {
|
||||
{ id: 'dashboard', label: 'Панель', icon: Activity },
|
||||
{ id: 'plants', label: 'Реестр растений', icon: Sprout },
|
||||
{ id: 'tasks', label: 'План работ', icon: CheckSquare },
|
||||
{ id: 'maintenance', label: 'Журнал', icon: Calendar },
|
||||
{ id: 'maintenance', label: 'Выполненные работы', icon: Calendar },
|
||||
{ id: 'observations', label: 'Дневник', icon: BookOpen },
|
||||
{ id: 'fertilizers', label: 'Удобрения', icon: FlaskConical },
|
||||
{ id: 'chemicals', label: 'Химикаты', icon: Beaker },
|
||||
{ id: 'stock', label: 'Заготовки', icon: Archive },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
@ -74,109 +76,129 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-green-50">
|
||||
<header className="bg-white shadow-sm border-b border-green-100">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="min-h-screen bg-green-50 flex">
|
||||
{/* Left Sidebar */}
|
||||
<div className="hidden md:flex md:w-64 md:flex-col">
|
||||
<div className="flex flex-col flex-grow bg-white shadow-sm border-r border-green-100">
|
||||
{/* 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" />
|
||||
<h1 className="text-2xl font-bold text-green-800">GardenTrack</h1>
|
||||
<h1 className="text-xl font-bold text-green-800">GardenTrack</h1>
|
||||
</div>
|
||||
<nav className="hidden md:flex space-x-6">
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-6 space-y-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTab === item.id
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'text-gray-600 hover:text-green-600 hover:bg-green-50'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === item.id
|
||||
? 'bg-green-100 text-green-700 border border-green-200'
|
||||
: 'text-gray-600 hover:text-green-600 hover:bg-green-50'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{activeTab === 'dashboard' && (
|
||||
<Dashboard
|
||||
plants={plants}
|
||||
tasks={tasks}
|
||||
maintenanceRecords={maintenanceRecords}
|
||||
harvestRecords={harvestRecords}
|
||||
onNavigate={setActiveTab}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'plants' && (
|
||||
<PlantRegistry
|
||||
plants={plants}
|
||||
onPlantsChange={setPlants}
|
||||
harvestRecords={harvestRecords}
|
||||
onHarvestRecordsChange={setHarvestRecords}
|
||||
maintenanceRecords={maintenanceRecords}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'tasks' && (
|
||||
<TaskPlanner
|
||||
tasks={tasks}
|
||||
plants={plants}
|
||||
onTasksChange={setTasks}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'maintenance' && (
|
||||
<MaintenanceLog
|
||||
maintenanceRecords={maintenanceRecords}
|
||||
plants={plants}
|
||||
onMaintenanceChange={setMaintenanceRecords}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'observations' && (
|
||||
<ObservationJournal
|
||||
observations={observations}
|
||||
plants={plants}
|
||||
onObservationsChange={setObservations}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'fertilizers' && (
|
||||
<FertilizerRegistry />
|
||||
)}
|
||||
{activeTab === 'chemicals' && (
|
||||
<ChemicalRegistry />
|
||||
)}
|
||||
</main>
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Mobile Header */}
|
||||
<header className="md:hidden bg-white shadow-sm border-b border-green-100">
|
||||
<div className="px-4 py-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Sprout className="h-6 w-6 text-green-600" />
|
||||
<h1 className="text-lg font-bold text-green-800">GardenTrack</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-green-100 px-4 py-2">
|
||||
<div className="flex justify-around">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={`flex flex-col items-center py-2 px-3 rounded-md transition-colors ${
|
||||
activeTab === item.id
|
||||
? 'text-green-600'
|
||||
: 'text-gray-400 hover:text-green-600'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="text-xs mt-1">{item.label.split(' ')[0]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 px-4 sm:px-6 lg:px-8 py-8 overflow-auto">
|
||||
{activeTab === 'dashboard' && (
|
||||
<Dashboard
|
||||
plants={plants}
|
||||
tasks={tasks}
|
||||
maintenanceRecords={maintenanceRecords}
|
||||
harvestRecords={harvestRecords}
|
||||
onNavigate={setActiveTab}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'plants' && (
|
||||
<PlantRegistry
|
||||
plants={plants}
|
||||
onPlantsChange={setPlants}
|
||||
harvestRecords={harvestRecords}
|
||||
onHarvestRecordsChange={setHarvestRecords}
|
||||
maintenanceRecords={maintenanceRecords}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'tasks' && (
|
||||
<TaskPlanner
|
||||
tasks={tasks}
|
||||
plants={plants}
|
||||
onTasksChange={setTasks}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'maintenance' && (
|
||||
<MaintenanceLog
|
||||
maintenanceRecords={maintenanceRecords}
|
||||
plants={plants}
|
||||
onMaintenanceChange={setMaintenanceRecords}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'observations' && (
|
||||
<ObservationJournal
|
||||
observations={observations}
|
||||
plants={plants}
|
||||
onObservationsChange={setObservations}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'fertilizers' && (
|
||||
<FertilizerRegistry />
|
||||
)}
|
||||
{activeTab === 'chemicals' && (
|
||||
<ChemicalRegistry />
|
||||
)}
|
||||
{activeTab === 'stock' && (
|
||||
<HarvestStockManager plants={plants} />
|
||||
)}
|
||||
</main>
|
||||
</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;
|
@ -231,7 +231,7 @@ const ChemicalRegistry: React.FC = () => {
|
||||
onClick={() => setShowForm(true)}
|
||||
className="mt-4 text-green-600 hover:text-green-700 transition-colors"
|
||||
>
|
||||
Add your first chemical →
|
||||
Добавте первую запись о химикате →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
@ -231,7 +231,7 @@ const FertilizerRegistry: React.FC = () => {
|
||||
onClick={() => setShowForm(true)}
|
||||
className="mt-4 text-green-600 hover:text-green-700 transition-colors"
|
||||
>
|
||||
Add your first fertilizer →
|
||||
Добавте первую запись о удобрении →
|
||||
</button>
|
||||
</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 { Plant, MaintenanceRecord } from '../types';
|
||||
import { Plant, MaintenanceRecord, Maintenances } from '../types';
|
||||
import { Plus, Calendar, Search, Scissors, Droplets, Beaker, Sprout } from 'lucide-react';
|
||||
import MaintenanceForm from './MaintenanceForm';
|
||||
import { apiService } from '../services/api';
|
||||
@ -146,9 +146,9 @@ const MaintenanceLog: React.FC<MaintenanceLogProps> = ({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 capitalize">{record.type}</h3>
|
||||
<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)}`}>
|
||||
{record.type.replace('-', ' ')}
|
||||
{Maintenances[record.type]}
|
||||
</span>
|
||||
{record.fertilizerName && (
|
||||
<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 {Plant, PlantObservation, PlantTitles} from '../types';
|
||||
import { X } from 'lucide-react';
|
||||
import ImageUpload from './ImageUpload';
|
||||
import { apiService } from '../services/api';
|
||||
|
||||
interface ObservationFormProps {
|
||||
observation?: PlantObservation | null;
|
||||
@ -21,6 +23,9 @@ const ObservationForm: React.FC<ObservationFormProps> = ({ observation, plants,
|
||||
tags: ''
|
||||
});
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (observation) {
|
||||
setFormData({
|
||||
@ -36,14 +41,33 @@ const ObservationForm: React.FC<ObservationFormProps> = ({ observation, plants,
|
||||
}
|
||||
}, [observation]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
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({
|
||||
...formData,
|
||||
plantId: parseInt(formData.plantId),
|
||||
temperature: formData.temperature ? parseFloat(formData.temperature) : undefined,
|
||||
weatherConditions: formData.weatherConditions || undefined,
|
||||
photoUrl: formData.photoUrl || undefined,
|
||||
photoUrl: finalPhotoUrl || undefined,
|
||||
tags: formData.tags || undefined
|
||||
});
|
||||
};
|
||||
@ -53,6 +77,14 @@ const ObservationForm: React.FC<ObservationFormProps> = ({ observation, plants,
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleFileSelect = (file: File) => {
|
||||
setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleImageChange = (imageUrl: string) => {
|
||||
setFormData(prev => ({ ...prev, photoUrl: imageUrl }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
@ -173,17 +205,14 @@ const ObservationForm: React.FC<ObservationFormProps> = ({ observation, plants,
|
||||
</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>
|
||||
<input
|
||||
type="url"
|
||||
id="photoUrl"
|
||||
name="photoUrl"
|
||||
value={formData.photoUrl}
|
||||
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"
|
||||
<ImageUpload
|
||||
currentImageUrl={formData.photoUrl}
|
||||
onImageChange={handleImageChange}
|
||||
onFileSelect={handleFileSelect}
|
||||
uploading={uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -214,10 +243,11 @@ const ObservationForm: React.FC<ObservationFormProps> = ({ observation, plants,
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors"
|
||||
type="submit"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -3,6 +3,7 @@ import { Plant, PlantObservation } from '../types';
|
||||
import { Plus, Search, Edit, Trash2, Thermometer, Cloud, Tag } from 'lucide-react';
|
||||
import ObservationForm from './ObservationForm';
|
||||
import { apiService } from '../services/api';
|
||||
import { ImagePreview } from "./utils/ImagePreview";
|
||||
|
||||
interface ObservationJournalProps {
|
||||
observations: PlantObservation[];
|
||||
@ -20,16 +21,23 @@ const ObservationJournal: React.FC<ObservationJournalProps> = ({
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterPlant, setFilterPlant] = useState('all');
|
||||
const [filterTag, setFilterTag] = useState('all');
|
||||
const [filterYear, setFilterYear] = useState('all');
|
||||
const [filterMonth, setFilterMonth] = useState('all');
|
||||
|
||||
const filteredObservations = observations.filter(observation => {
|
||||
const plant = plants.find(p => p.id === observation.plantId);
|
||||
const observationDate = new Date(observation.date);
|
||||
|
||||
const matchesSearch = observation.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
observation.observation.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(plant?.variety.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const matchesPlant = filterPlant === 'all' || observation.plantId.toString() === filterPlant;
|
||||
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());
|
||||
|
||||
const allTags = Array.from(new Set(
|
||||
@ -38,6 +46,27 @@ const ObservationJournal: React.FC<ObservationJournalProps> = ({
|
||||
.flatMap(obs => obs.tags!.split(',').map(tag => tag.trim()))
|
||||
)).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'>) => {
|
||||
try {
|
||||
if (editingObservation) {
|
||||
@ -87,7 +116,7 @@ const ObservationJournal: React.FC<ObservationJournalProps> = ({
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-green-100">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Поиск</label>
|
||||
<div className="relative">
|
||||
@ -102,6 +131,38 @@ const ObservationJournal: React.FC<ObservationJournalProps> = ({
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Растения</label>
|
||||
<select
|
||||
@ -142,101 +203,118 @@ const ObservationJournal: React.FC<ObservationJournalProps> = ({
|
||||
const plant = plants.find(p => p.id === observation.plantId);
|
||||
|
||||
return (
|
||||
<div key={observation.id} className="bg-white rounded-lg shadow-sm border border-green-100 p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{observation.title}</h3>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div
|
||||
key={observation.id}
|
||||
className="bg-white rounded-lg shadow-sm border border-green-100 p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start">
|
||||
{/* Левая часть — картинка */}
|
||||
{observation.photoUrl && (
|
||||
<div className="mt-4">
|
||||
<img
|
||||
src={observation.photoUrl}
|
||||
alt={observation.title}
|
||||
className="w-full max-w-md h-48 object-cover rounded-lg"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-40 h-40 flex-shrink-0 mr-6">
|
||||
<ImagePreview
|
||||
thumbnailSrc={observation.photoUrl.startsWith('/uploads')
|
||||
? `${import.meta.env.VITE_API_URL || '/api'}${observation.photoUrl}`.replace('/api/uploads', '/uploads')
|
||||
: observation.photoUrl
|
||||
}
|
||||
|
||||
<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>
|
||||
fullSrc={observation.photoUrl.startsWith('/uploads')
|
||||
? `${import.meta.env.VITE_API_URL || '/api'}${observation.photoUrl}`.replace('/api/uploads', '/uploads')
|
||||
: observation.photoUrl
|
||||
}
|
||||
|
||||
alt={observation.title}
|
||||
className="w-32 h-32 object-cover cursor-pointer rounded shadow"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Правая часть — текст */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{observation.title}</h3>
|
||||
<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>
|
||||
|
||||
{filteredObservations.length === 0 && (
|
||||
<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
|
||||
onClick={() => setShowObservationForm(true)}
|
||||
className="mt-4 text-green-600 hover:text-green-700 transition-colors"
|
||||
>
|
||||
Добавить первое наблюдение →
|
||||
{observations.length === 0 ? 'Добавить первое наблюдение →' : 'Добавить новое наблюдение →'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plant } from '../types';
|
||||
import { X, Upload, Image } from 'lucide-react';
|
||||
import { X } from 'lucide-react';
|
||||
import { apiService } from '../services/api';
|
||||
import ImageUpload from './ImageUpload';
|
||||
|
||||
interface PlantFormProps {
|
||||
plant?: Plant | null;
|
||||
@ -42,14 +43,33 @@ const PlantForm: React.FC<PlantFormProps> = ({ plant, onSave, onCancel }) => {
|
||||
}
|
||||
}, [plant]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
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({
|
||||
...formData,
|
||||
seedlingAge: parseInt(formData.seedlingAge),
|
||||
seedlingHeight: parseFloat(formData.seedlingHeight),
|
||||
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 }));
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
}
|
||||
const handleFileSelect = (file: File) => {
|
||||
setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleUploadPhoto = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
const result = await apiService.uploadPhoto(selectedFile);
|
||||
setFormData(prev => ({ ...prev, photoUrl: result.photoUrl }));
|
||||
setSelectedFile(null);
|
||||
} catch (error) {
|
||||
console.error('Error uploading photo:', error);
|
||||
alert('Failed to upload photo. Please try again.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
const handleImageChange = (imageUrl: string) => {
|
||||
setFormData(prev => ({ ...prev, photoUrl: imageUrl }));
|
||||
};
|
||||
|
||||
return (
|
||||
@ -239,88 +244,16 @@ const PlantForm: React.FC<PlantFormProps> = ({ plant, onSave, onCancel }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="photoUrl" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Фотография
|
||||
</label>
|
||||
|
||||
{/* Photo Upload Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Plant Photo
|
||||
</label>
|
||||
<ImageUpload
|
||||
currentImageUrl={formData.photoUrl}
|
||||
onImageChange={handleImageChange}
|
||||
onFileSelect={handleFileSelect}
|
||||
uploading={uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -347,10 +280,11 @@ const PlantForm: React.FC<PlantFormProps> = ({ plant, onSave, onCancel }) => {
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors"
|
||||
type="submit"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -1,10 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
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 HarvestForm from './HarvestForm';
|
||||
import PlantStatistics from './PlantStatistics';
|
||||
import { apiService } from '../services/api';
|
||||
import HarvestProcessingModal from './HarvestProcessingModal';
|
||||
import { ImagePreview } from "./utils/ImagePreview.tsx";
|
||||
|
||||
interface PlantRegistryProps {
|
||||
plants: Plant[];
|
||||
@ -14,6 +16,9 @@ interface PlantRegistryProps {
|
||||
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> = ({
|
||||
plants,
|
||||
onPlantsChange,
|
||||
@ -27,6 +32,8 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
|
||||
const [editingPlant, setEditingPlant] = useState<Plant | null>(null);
|
||||
const [selectedPlantForHarvest, setSelectedPlantForHarvest] = 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 [filterType, setFilterType] = useState('all');
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
@ -210,7 +217,7 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
|
||||
setShowStatistics(true);
|
||||
}}
|
||||
className="p-1 text-purple-600 hover:text-purple-700 transition-colors"
|
||||
title="Посмотреть Статистику"
|
||||
title="Статистика"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</button>
|
||||
@ -220,10 +227,25 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
|
||||
setShowHarvestForm(true);
|
||||
}}
|
||||
className="p-1 text-green-600 hover:text-green-700 transition-colors"
|
||||
title="Добавить урожай"
|
||||
title="Урожай"
|
||||
>
|
||||
<Award className="h-4 w-4" />
|
||||
</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
|
||||
onClick={() => {
|
||||
setEditingPlant(plant);
|
||||
@ -245,16 +267,19 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
|
||||
<div className="space-y-2">
|
||||
{plant.photoUrl && (
|
||||
<div className="mb-3">
|
||||
<img
|
||||
src={plant.photoUrl.startsWith('/uploads')
|
||||
? `${import.meta.env.VITE_API_URL || '/api'}${plant.photoUrl}`.replace('/api/uploads', '/uploads')
|
||||
: plant.photoUrl
|
||||
}
|
||||
alt={plant.variety}
|
||||
className="w-full h-32 object-cover rounded-lg"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
<ImagePreview
|
||||
thumbnailSrc={plant.photoUrl.startsWith('/uploads')
|
||||
? `${import.meta.env.VITE_API_URL || '/api'}${plant.photoUrl}`.replace('/api/uploads', '/uploads')
|
||||
: 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}
|
||||
className="w-32 h-32 object-cover cursor-pointer rounded shadow"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -323,25 +348,25 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Plant
|
||||
Растение
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
Тип
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Planted
|
||||
Посажено
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Height
|
||||
Высота
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
Состояние
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Last Harvest
|
||||
Последний урожай
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -358,16 +383,19 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
|
||||
<div className="flex items-center">
|
||||
{plant.photoUrl && (
|
||||
<div className="flex-shrink-0 h-10 w-10 mr-3">
|
||||
<img
|
||||
src={plant.photoUrl.startsWith('/uploads')
|
||||
<ImagePreview
|
||||
thumbnailSrc={plant.photoUrl.startsWith('/uploads')
|
||||
? `${import.meta.env.VITE_API_URL || '/api'}${plant.photoUrl}`.replace('/api/uploads', '/uploads')
|
||||
: 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}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -435,6 +463,21 @@ const PlantRegistry: React.FC<PlantRegistryProps> = ({
|
||||
>
|
||||
<Award className="h-4 w-4" />
|
||||
</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
|
||||
onClick={() => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -179,30 +179,70 @@ const PlantStatisticsComponent: React.FC<PlantStatisticsProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h4 className="text-lg font-semibold text-gray-900 mb-4">График сбора урожая</h4>
|
||||
<div className="space-y-2">
|
||||
{statistics.harvestDates.map((date, index) => {
|
||||
const harvest = harvestRecords.find(h => h.date === date && h.plantId === plant.id);
|
||||
return (
|
||||
<div key={index} className="py-3 px-4 bg-white rounded-lg border border-green-100">
|
||||
<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>
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
// 1. Берём только урожай этого растения за выбранный год
|
||||
const plantHarvests = harvestRecords.filter(
|
||||
h => h.plantId === plant.id && new Date(h.date).getFullYear() === selectedYear
|
||||
);
|
||||
})}
|
||||
|
||||
// 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>
|
||||
)}
|
||||
|
@ -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="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{task ? 'Edit Task' : 'Create New Task'}
|
||||
{task ? 'Изменение работы' : 'Создание новой работы'}
|
||||
</h2>
|
||||
<button
|
||||
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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Task Title *
|
||||
Наименование работы *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@ -164,7 +164,7 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Type *
|
||||
Тип *
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
@ -182,7 +182,7 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Deadline *
|
||||
Крайний срок *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
@ -195,7 +195,7 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Status
|
||||
Статус
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<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"
|
||||
/>
|
||||
<label htmlFor="completed" className="ml-2 block text-sm text-gray-700">
|
||||
Completed
|
||||
Завершена
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Plant (Optional)
|
||||
Растение (не обязательно)
|
||||
</label>
|
||||
<select
|
||||
value={formData.plantId}
|
||||
@ -256,7 +256,7 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
|
||||
<p><strong>Application Rate:</strong> {selectedFertilizer.applicationRate}</p>
|
||||
<p><strong>Frequency:</strong> {selectedFertilizer.frequency}</p>
|
||||
{selectedFertilizer.notes && (
|
||||
<p><strong>Notes:</strong> {selectedFertilizer.notes}</p>
|
||||
<p><strong>Заметки:</strong> {selectedFertilizer.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -295,7 +295,7 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
|
||||
</p>
|
||||
)}
|
||||
{selectedChemical.notes && (
|
||||
<p><strong>Notes:</strong> {selectedChemical.notes}</p>
|
||||
<p><strong>Заметки:</strong> {selectedChemical.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -305,7 +305,7 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Description
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
@ -322,13 +322,13 @@ export default function TaskForm({ isOpen, onClose, onSubmit, task }: TaskFormPr
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -114,15 +114,15 @@ const TaskPlanner: React.FC<TaskPlannerProps> = ({ tasks, plants, onTasksChange
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-green-800">Task Planner</h2>
|
||||
<p className="text-green-600 mt-1">Plan and track your garden maintenance tasks</p>
|
||||
<h2 className="text-3xl font-bold text-green-800">План работ</h2>
|
||||
<p className="text-green-600 mt-1">Планируйте и отслеживайте свои задачи по уходу за садом</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowTaskForm(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center space-x-2 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Task</span>
|
||||
<span>Новая работа</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -198,7 +198,7 @@ const TaskPlanner: React.FC<TaskPlannerProps> = ({ tasks, plants, onTasksChange
|
||||
|
||||
{plant && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
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
|
||||
async getPlantObservations(): Promise<PlantObservation[]> {
|
||||
return this.makeRequest<PlantObservation[]>('/observations');
|
||||
const obs = this.makeRequest<PlantObservation[]>('/observations');
|
||||
console.log(obs);
|
||||
return obs;
|
||||
}
|
||||
|
||||
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
|
||||
|
1
src/stub-empty.js
Normal file
@ -0,0 +1 @@
|
||||
export default {};
|
@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
|
||||
export interface Plant {
|
||||
id: number;
|
||||
@ -55,6 +54,32 @@ export interface HarvestRecord {
|
||||
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 {
|
||||
id: number;
|
||||
plantId: number;
|
||||
|
@ -7,4 +7,9 @@ export default defineConfig({
|
||||
optimizeDeps: {
|
||||
exclude: ['lucide-react'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'lucide-react/dist/esm/icons/fingerprint.js': '/src/stub-empty.js'
|
||||
}
|
||||
},
|
||||
});
|
||||
|