diff --git a/.perm_test_apache b/.perm_test_apache new file mode 100644 index 0000000..e69de29 diff --git a/.perm_test_exec b/.perm_test_exec new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/index.js b/backend/src/index.js index 1f8736b..8fd7d59 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -18,7 +18,7 @@ const sqlRoutes = require('./routes/sql'); const pexelsRoutes = require('./routes/pexels'); const openaiRoutes = require('./routes/openai'); - +const ikiguziRoutes = require('./routes/ikiguzi'); const usersRoutes = require('./routes/users'); @@ -131,6 +131,8 @@ app.use('/api/cooperatives', passport.authenticate('jwt', {session: false}), coo app.use('/api/cooperative_memberships', passport.authenticate('jwt', {session: false}), cooperative_membershipsRoutes); +app.use('/api/ikiguzi', passport.authenticate('jwt', { session: false }), ikiguziRoutes); + app.use( '/api/openai', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/ikiguzi.js b/backend/src/routes/ikiguzi.js new file mode 100644 index 0000000..43d027f --- /dev/null +++ b/backend/src/routes/ikiguzi.js @@ -0,0 +1,24 @@ +const express = require('express'); + +const IkiguziService = require('../services/ikiguzi'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +router.get( + '/overview', + wrapAsync(async (req, res) => { + const payload = await IkiguziService.getOverview(req.query.cropId, req.currentUser); + res.status(200).send(payload); + }), +); + +router.post( + '/quick-log', + wrapAsync(async (req, res) => { + const payload = await IkiguziService.quickLogCost(req.body, req.currentUser); + res.status(200).send(payload); + }), +); + +module.exports = router; diff --git a/backend/src/services/ikiguzi.js b/backend/src/services/ikiguzi.js new file mode 100644 index 0000000..1e66022 --- /dev/null +++ b/backend/src/services/ikiguzi.js @@ -0,0 +1,447 @@ +const db = require('../db/models'); + +const { Op } = db.Sequelize; + +module.exports = class IkiguziService { + static normalizeNumber(value, fieldName, options = {}) { + const { required = false, min = 0 } = options; + + if (value === undefined || value === null || value === '') { + if (required) { + const error = new Error(`${fieldName} is required`); + error.code = 400; + throw error; + } + + return null; + } + + const parsed = Number(value); + + if (!Number.isFinite(parsed) || parsed < min) { + const error = new Error(`${fieldName} must be a valid number`); + error.code = 400; + throw error; + } + + return parsed; + } + + static serializeCostRecord(record) { + return { + id: record.id, + cost_type: record.cost_type, + amount: Number(record.amount || 0), + currency: record.currency, + vendor_name: record.vendor_name, + notes: record.notes, + entry_date: record.entry_date, + cropId: record.cropId, + createdAt: record.createdAt, + }; + } + + static serializePrediction(record) { + if (!record) { + return null; + } + + return { + id: record.id, + predicted_price_per_unit: Number(record.predicted_price_per_unit || 0), + currency: record.currency, + unit_name: record.unit_name, + confidence_percent: record.confidence_percent, + model_name: record.model_name, + explanation: record.explanation, + generated_at: record.generated_at, + valid_until: record.valid_until, + alert_triggered: record.alert_triggered, + }; + } + + static serializeMarketPrice(record) { + if (!record) { + return null; + } + + return { + id: record.id, + crop_name: record.crop_name, + market_name: record.market_name, + region_name: record.region_name, + price_per_unit: Number(record.price_per_unit || 0), + currency: record.currency, + unit_name: record.unit_name, + price_date: record.price_date, + data_quality_note: record.data_quality_note, + }; + } + + static serializeThreshold(record) { + if (!record) { + return null; + } + + return { + id: record.id, + crop_name: record.crop_name, + region_name: record.region_name, + target_price_per_unit: Number(record.target_price_per_unit || 0), + currency: record.currency, + unit_name: record.unit_name, + is_enabled: record.is_enabled, + effective_from: record.effective_from, + effective_until: record.effective_until, + updatedAt: record.updatedAt, + }; + } + + static buildRecommendation({ latestPrediction, latestPrice, threshold }) { + if (!latestPrediction && !latestPrice) { + return { + tone: 'build', + title: 'Waiting for market intelligence', + message: + 'Add a market prediction or sync price data to unlock a stronger sell recommendation for this crop.', + }; + } + + if (!threshold) { + return { + tone: 'watch', + title: 'Good tracking foundation', + message: + 'You are recording costs and market signals. Add a target selling price to receive a clearer sell-or-wait signal.', + }; + } + + const predictedPrice = latestPrediction + ? Number(latestPrediction.predicted_price_per_unit || 0) + : null; + const observedPrice = latestPrice + ? Number(latestPrice.price_per_unit || 0) + : null; + const targetPrice = Number(threshold.target_price_per_unit || 0); + + const predictionReady = predictedPrice !== null && predictedPrice >= targetPrice; + const marketReady = observedPrice !== null && observedPrice >= targetPrice; + + if (predictionReady && marketReady) { + return { + tone: 'sell', + title: 'Strong selling window', + message: + 'Both the latest market price and the AI forecast are above your target price. This crop is ready for a selling alert.', + }; + } + + if (predictionReady) { + return { + tone: 'watch', + title: 'Forecast looks promising', + message: + 'The AI prediction is above your target price, but the live market price has not fully caught up yet. Keep watching the next update.', + }; + } + + if (marketReady) { + return { + tone: 'watch', + title: 'Market spike detected', + message: + 'The latest market price meets your target, but the prediction is more cautious. Consider selling part of your stock and monitoring the trend.', + }; + } + + return { + tone: 'build', + title: 'Hold and monitor', + message: + 'Current prices are still below your target. Keep tracking costs and wait for a stronger selling signal.', + }; + } + + static async getOverview(cropId, currentUser) { + let crop = null; + + if (cropId) { + crop = await db.crops.findByPk(cropId); + } + + if (!crop) { + crop = await db.crops.findOne({ + order: [['createdAt', 'DESC']], + }); + } + + if (!crop) { + return { + crop: null, + totals: { + totalCost: 0, + entryCount: 0, + breakdown: [], + }, + latestPrediction: null, + latestPrice: null, + threshold: null, + recentCostRecords: [], + recentPredictions: [], + recentPrices: [], + recommendation: { + tone: 'build', + title: 'Create your first crop', + message: + 'Start by registering a crop. Then you can log production costs, compare them with market forecasts, and activate selling alerts.', + }, + }; + } + + const totalCostRow = await db.cost_records.findOne({ + attributes: [ + [db.sequelize.fn('COALESCE', db.sequelize.fn('SUM', db.sequelize.col('amount')), 0), 'totalCost'], + [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'entryCount'], + ], + where: { + cropId: crop.id, + }, + raw: true, + }); + + const breakdownRows = await db.cost_records.findAll({ + attributes: [ + 'cost_type', + [db.sequelize.fn('COALESCE', db.sequelize.fn('SUM', db.sequelize.col('amount')), 0), 'total'], + ], + where: { + cropId: crop.id, + }, + group: ['cost_type'], + raw: true, + }); + + const recentCostRecords = await db.cost_records.findAll({ + where: { + cropId: crop.id, + }, + order: [ + ['entry_date', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: 5, + }); + + const recentPredictions = await db.market_predictions.findAll({ + where: { + cropId: crop.id, + }, + order: [ + ['generated_at', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: 4, + }); + + const marketPriceWhere = { + crop_name: crop.crop_name, + }; + + if (crop.region_name) { + marketPriceWhere.region_name = { + [Op.or]: [crop.region_name, null], + }; + } + + const recentPrices = await db.market_prices.findAll({ + where: marketPriceWhere, + order: [ + ['price_date', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: 4, + }); + + const thresholdWhere = { + crop_name: crop.crop_name, + is_enabled: true, + }; + + if (currentUser && currentUser.id) { + thresholdWhere.userId = currentUser.id; + } + + if (crop.region_name) { + thresholdWhere.region_name = crop.region_name; + } + + const latestThreshold = await db.selling_thresholds.findOne({ + where: thresholdWhere, + order: [ + ['effective_from', 'DESC'], + ['updatedAt', 'DESC'], + ], + }); + + const latestPrediction = recentPredictions[0] || null; + const latestPrice = recentPrices[0] || null; + + return { + crop: { + id: crop.id, + crop_name: crop.crop_name, + crop_category: crop.crop_category, + season_name: crop.season_name, + region_name: crop.region_name, + area_hectares: Number(crop.area_hectares || 0), + planting_date: crop.planting_date, + expected_harvest_date: crop.expected_harvest_date, + }, + totals: { + totalCost: Number(totalCostRow?.totalCost || 0), + entryCount: Number(totalCostRow?.entryCount || 0), + breakdown: breakdownRows + .map((row) => ({ + cost_type: row.cost_type, + total: Number(row.total || 0), + })) + .sort((left, right) => right.total - left.total), + }, + latestPrediction: IkiguziService.serializePrediction(latestPrediction), + latestPrice: IkiguziService.serializeMarketPrice(latestPrice), + threshold: IkiguziService.serializeThreshold(latestThreshold), + recentCostRecords: recentCostRecords.map(IkiguziService.serializeCostRecord), + recentPredictions: recentPredictions.map(IkiguziService.serializePrediction), + recentPrices: recentPrices.map(IkiguziService.serializeMarketPrice), + recommendation: IkiguziService.buildRecommendation({ + latestPrediction, + latestPrice, + threshold: latestThreshold, + }), + }; + } + + static async quickLogCost(data, currentUser) { + if (!currentUser || !currentUser.id) { + const error = new Error('Authentication is required'); + error.code = 401; + throw error; + } + + const cropId = data.cropId; + const costType = data.costType; + const amount = IkiguziService.normalizeNumber(data.amount, 'amount', { + required: true, + min: 0, + }); + const targetPricePerUnit = IkiguziService.normalizeNumber( + data.targetPricePerUnit, + 'targetPricePerUnit', + { min: 0 }, + ); + + if (!cropId) { + const error = new Error('cropId is required'); + error.code = 400; + throw error; + } + + if (!costType) { + const error = new Error('costType is required'); + error.code = 400; + throw error; + } + + const crop = await db.crops.findByPk(cropId); + + if (!crop) { + const error = new Error('Crop not found'); + error.code = 404; + throw error; + } + + const transaction = await db.sequelize.transaction(); + + try { + await db.cost_records.create( + { + cropId, + entry_date: data.entryDate || new Date(), + cost_type: costType, + amount, + currency: data.currency || 'RWF', + vendor_name: data.vendorName || null, + notes: data.notes || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + let thresholdAction = null; + + if (targetPricePerUnit !== null) { + const thresholdWhere = { + userId: currentUser.id, + crop_name: crop.crop_name, + is_enabled: true, + }; + + if (crop.region_name) { + thresholdWhere.region_name = crop.region_name; + } + + const existingThreshold = await db.selling_thresholds.findOne({ + where: thresholdWhere, + order: [ + ['effective_from', 'DESC'], + ['updatedAt', 'DESC'], + ], + transaction, + }); + + const thresholdPayload = { + userId: currentUser.id, + crop_name: crop.crop_name, + region_name: crop.region_name || null, + target_price_per_unit: targetPricePerUnit, + unit_name: data.unitName || 'kg', + currency: data.currency || 'RWF', + is_enabled: true, + effective_from: data.entryDate || new Date(), + updatedById: currentUser.id, + }; + + if (existingThreshold) { + await existingThreshold.update(thresholdPayload, { transaction }); + thresholdAction = 'updated'; + } else { + await db.selling_thresholds.create( + { + ...thresholdPayload, + createdById: currentUser.id, + }, + { transaction }, + ); + thresholdAction = 'created'; + } + } + + await transaction.commit(); + + const overview = await IkiguziService.getOverview(cropId, currentUser); + + const actionText = thresholdAction + ? `Logged ${costType} cost for ${crop.crop_name} and ${thresholdAction} your sell alert.` + : `Logged ${costType} cost for ${crop.crop_name}.`; + + return { + message: actionText, + overview, + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index f593621..228fc83 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,11 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/farm-workspace', + icon: icon.mdiChartTimelineVariant, + label: 'Farm workspace', + }, { href: '/users/users-list', diff --git a/frontend/src/pages/farm-workspace.tsx b/frontend/src/pages/farm-workspace.tsx new file mode 100644 index 0000000..e62db9b --- /dev/null +++ b/frontend/src/pages/farm-workspace.tsx @@ -0,0 +1,732 @@ +import { + mdiBellAlert, + mdiCashMultiple, + mdiChartTimelineVariant, + mdiRobot, +} from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import React, { type ChangeEvent, type FormEvent, type ReactElement, useEffect, useState } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import BaseDivider from '../components/BaseDivider'; +import CardBox from '../components/CardBox'; +import FormField from '../components/FormField'; +import NotificationBar from '../components/NotificationBar'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { useAppSelector } from '../stores/hooks'; + +type CropOption = { + id: string; + crop_name: string; + crop_category?: string; + region_name?: string; + season_name?: string; + area_hectares?: number | string; +}; + +type OverviewData = { + crop: CropOption | null; + totals: { + totalCost: number; + entryCount: number; + breakdown: Array<{ + cost_type: string; + total: number; + }>; + }; + latestPrediction: any; + latestPrice: any; + threshold: any; + recentCostRecords: any[]; + recentPredictions: any[]; + recentPrices: any[]; + recommendation: { + tone: 'sell' | 'watch' | 'build'; + title: string; + message: string; + }; +}; + +type QuickLogForm = { + cropId: string; + costType: string; + amount: string; + currency: string; + entryDate: string; + vendorName: string; + targetPricePerUnit: string; + unitName: string; + notes: string; +}; + +const toLocalDateTimeInput = (value?: string | Date) => { + const date = value ? new Date(value) : new Date(); + const timezoneOffset = date.getTimezoneOffset() * 60_000; + + return new Date(date.getTime() - timezoneOffset).toISOString().slice(0, 16); +}; + +const defaultFormValues = (): QuickLogForm => ({ + cropId: '', + costType: 'seeds', + amount: '', + currency: 'RWF', + entryDate: toLocalDateTimeInput(), + vendorName: '', + targetPricePerUnit: '', + unitName: 'kg', + notes: '', +}); + +const formatMoney = (amount?: number | string | null, currency = 'RWF') => { + const numericAmount = Number(amount || 0); + + return `${currency} ${numericAmount.toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + })}`; +}; + +const formatDate = (value?: string | Date | null) => { + if (!value) { + return 'Not set'; + } + + return new Date(value).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +}; + +const toneStyles: Record = { + sell: { + badge: 'bg-emerald-100 text-emerald-700', + panel: 'border-emerald-200 bg-emerald-50', + }, + watch: { + badge: 'bg-amber-100 text-amber-700', + panel: 'border-amber-200 bg-amber-50', + }, + build: { + badge: 'bg-sky-100 text-sky-700', + panel: 'border-sky-200 bg-sky-50', + }, +}; + +const FarmWorkspace = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const [crops, setCrops] = useState([]); + const [formState, setFormState] = useState(defaultFormValues); + const [overview, setOverview] = useState(null); + const [isLoadingCrops, setIsLoadingCrops] = useState(true); + const [isLoadingOverview, setIsLoadingOverview] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + const loadCrops = async () => { + try { + setIsLoadingCrops(true); + const response = await axios.get('/crops?limit=100&page=0'); + const nextCrops = Array.isArray(response.data?.rows) ? response.data.rows : []; + setCrops(nextCrops); + + const firstCropId = nextCrops[0]?.id || ''; + setFormState((previousState) => ({ + ...previousState, + cropId: previousState.cropId || firstCropId, + })); + } catch (error: any) { + console.error('Failed to load crops for workspace', error); + setErrorMessage(error?.response?.data || 'Unable to load crops right now.'); + } finally { + setIsLoadingCrops(false); + } + }; + + loadCrops(); + }, []); + + useEffect(() => { + const loadOverview = async () => { + if (!formState.cropId) { + setOverview(null); + return; + } + + try { + setIsLoadingOverview(true); + const response = await axios.get(`/ikiguzi/overview?cropId=${formState.cropId}`); + setOverview(response.data); + } catch (error: any) { + console.error('Failed to load Ikiguzi overview', error); + setErrorMessage(error?.response?.data || 'Unable to load crop insights right now.'); + } finally { + setIsLoadingOverview(false); + } + }; + + loadOverview(); + }, [formState.cropId]); + + const handleInputChange = ( + event: ChangeEvent, + ) => { + const { name, value } = event.target; + + setFormState((previousState) => ({ + ...previousState, + [name]: value, + })); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setSuccessMessage(''); + setErrorMessage(''); + + if (!formState.cropId) { + setErrorMessage('Please select a crop before logging a cost.'); + return; + } + + if (!formState.amount || Number(formState.amount) <= 0) { + setErrorMessage('Please enter a valid cost amount greater than zero.'); + return; + } + + try { + setIsSubmitting(true); + const response = await axios.post('/ikiguzi/quick-log', formState); + setSuccessMessage(response.data?.message || 'Cost logged successfully.'); + setOverview(response.data?.overview || null); + setFormState((previousState) => ({ + ...defaultFormValues(), + cropId: previousState.cropId, + currency: previousState.currency, + targetPricePerUnit: previousState.targetPricePerUnit, + unitName: previousState.unitName, + })); + } catch (error: any) { + console.error('Failed to submit quick log', error); + setErrorMessage(error?.response?.data || 'Unable to save this entry. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + const selectedCrop = crops.find((crop) => crop.id === formState.cropId) || overview?.crop || null; + const recommendationTone = overview?.recommendation?.tone || 'build'; + const recommendationStyle = toneStyles[recommendationTone] || toneStyles.build; + + return ( + <> + + {getPageTitle('Farm workspace')} + + + + {''} + + +
+
+
+
+ Ikiguzi MVP slice · cost logging + sell signal +
+

+ {currentUser?.firstName ? `Welcome back, ${currentUser.firstName}. ` : ''} + Track farm costs fast, compare them with market signals, and prepare a smarter selling move. +

+

+ This workspace gives farmers one practical flow: log a production cost, keep a target selling price, + and immediately see whether the crop looks ready to sell, watch, or hold. +

+ + + + + +
+
+
+

Tracked cost

+

+ {formatMoney(overview?.totals?.totalCost || 0, formState.currency)} +

+

Across {overview?.totals?.entryCount || 0} logged entries

+
+
+

Latest forecast

+

+ {overview?.latestPrediction + ? formatMoney( + overview.latestPrediction.predicted_price_per_unit, + overview.latestPrediction.currency, + ) + : '—'} +

+

+ {overview?.latestPrediction + ? `${overview.latestPrediction.confidence_percent || 0}% confidence` + : 'No prediction yet'} +

+
+
+

Target alert

+

+ {overview?.threshold + ? formatMoney( + overview.threshold.target_price_per_unit, + overview.threshold.currency, + ) + : 'Off'} +

+

+ {overview?.threshold ? `Per ${overview.threshold.unit_name || 'unit'}` : 'Set during quick log'} +

+
+
+
+
+ + {successMessage ? ( + + {successMessage} + + ) : null} + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + {isLoadingCrops ? ( + +
+

Loading farm workspace…

+

We are preparing your crop list and the latest pricing context.

+
+
+ ) : null} + + {!isLoadingCrops && crops.length === 0 ? ( + +
+
+

No crops yet

+

+ Your first useful workflow starts with one crop record. After that, you can log costs and monitor + selling signals in this workspace. +

+
+ + + + +
+
+ ) : null} + + {!isLoadingCrops && crops.length > 0 ? ( +
+ +
+
+

Step 1

+

Quick cost log

+
+
+ Mobile-friendly input +
+
+

+ Save one real production expense and optionally update the selling target for the crop in the same action. +

+ + + +
+ + + + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + +
+ + + + + + +
+ + +