Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0d23b6018 |
0
.perm_test_apache
Normal file
0
.perm_test_apache
Normal file
0
.perm_test_exec
Normal file
0
.perm_test_exec
Normal file
@ -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 }),
|
||||
|
||||
24
backend/src/routes/ikiguzi.js
Normal file
24
backend/src/routes/ikiguzi.js
Normal file
@ -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;
|
||||
447
backend/src/services/ikiguzi.js
Normal file
447
backend/src/services/ikiguzi.js
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
732
frontend/src/pages/farm-workspace.tsx
Normal file
732
frontend/src/pages/farm-workspace.tsx
Normal file
@ -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<string, { badge: string; panel: string }> = {
|
||||
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<CropOption[]>([]);
|
||||
const [formState, setFormState] = useState<QuickLogForm>(defaultFormValues);
|
||||
const [overview, setOverview] = useState<OverviewData | null>(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<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
const { name, value } = event.target;
|
||||
|
||||
setFormState((previousState) => ({
|
||||
...previousState,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Farm workspace')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Farm workspace" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className="mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-emerald-950 via-emerald-800 to-sky-600 p-8 text-white shadow-2xl">
|
||||
<div className="grid gap-6 lg:grid-cols-[1.3fr,0.9fr] lg:items-end">
|
||||
<div>
|
||||
<div className="mb-4 inline-flex items-center rounded-full bg-white/15 px-4 py-2 text-sm font-medium text-emerald-50 backdrop-blur">
|
||||
Ikiguzi MVP slice · cost logging + sell signal
|
||||
</div>
|
||||
<h1 className="max-w-3xl text-4xl font-bold leading-tight md:text-5xl">
|
||||
{currentUser?.firstName ? `Welcome back, ${currentUser.firstName}. ` : ''}
|
||||
Track farm costs fast, compare them with market signals, and prepare a smarter selling move.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-base text-emerald-50/90 md:text-lg">
|
||||
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.
|
||||
</p>
|
||||
<BaseButtons className="mt-6" type="justify-start" noWrap={false}>
|
||||
<BaseButton href="/crops/crops-new" color="info" label="Register crop" />
|
||||
<BaseButton href="/market_predictions/market_predictions-list" color="whiteDark" label="Review AI predictions" />
|
||||
<BaseButton href="/selling_thresholds/selling_thresholds-list" color="whiteDark" label="Open alerts" />
|
||||
</BaseButtons>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3 lg:grid-cols-1">
|
||||
<div className="rounded-2xl border border-white/15 bg-white/10 p-4 backdrop-blur">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-emerald-100/70">Tracked cost</p>
|
||||
<p className="mt-2 text-3xl font-semibold">
|
||||
{formatMoney(overview?.totals?.totalCost || 0, formState.currency)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-emerald-50/80">Across {overview?.totals?.entryCount || 0} logged entries</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/15 bg-white/10 p-4 backdrop-blur">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-emerald-100/70">Latest forecast</p>
|
||||
<p className="mt-2 text-3xl font-semibold">
|
||||
{overview?.latestPrediction
|
||||
? formatMoney(
|
||||
overview.latestPrediction.predicted_price_per_unit,
|
||||
overview.latestPrediction.currency,
|
||||
)
|
||||
: '—'}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-emerald-50/80">
|
||||
{overview?.latestPrediction
|
||||
? `${overview.latestPrediction.confidence_percent || 0}% confidence`
|
||||
: 'No prediction yet'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/15 bg-white/10 p-4 backdrop-blur">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-emerald-100/70">Target alert</p>
|
||||
<p className="mt-2 text-3xl font-semibold">
|
||||
{overview?.threshold
|
||||
? formatMoney(
|
||||
overview.threshold.target_price_per_unit,
|
||||
overview.threshold.currency,
|
||||
)
|
||||
: 'Off'}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-emerald-50/80">
|
||||
{overview?.threshold ? `Per ${overview.threshold.unit_name || 'unit'}` : 'Set during quick log'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{successMessage ? (
|
||||
<NotificationBar color="success" icon={mdiBellAlert}>
|
||||
{successMessage}
|
||||
</NotificationBar>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<NotificationBar color="danger" icon={mdiBellAlert}>
|
||||
{errorMessage}
|
||||
</NotificationBar>
|
||||
) : null}
|
||||
|
||||
{isLoadingCrops ? (
|
||||
<CardBox className="mb-6">
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-semibold text-gray-900">Loading farm workspace…</p>
|
||||
<p className="text-sm text-gray-500">We are preparing your crop list and the latest pricing context.</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
) : null}
|
||||
|
||||
{!isLoadingCrops && crops.length === 0 ? (
|
||||
<CardBox className="mb-6">
|
||||
<div className="grid gap-4 md:grid-cols-[1.4fr,auto] md:items-center">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-gray-900">No crops yet</p>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Your first useful workflow starts with one crop record. After that, you can log costs and monitor
|
||||
selling signals in this workspace.
|
||||
</p>
|
||||
</div>
|
||||
<BaseButtons className="md:justify-end" type="justify-start">
|
||||
<BaseButton href="/crops/crops-new" color="info" label="Create first crop" />
|
||||
<BaseButton href="/crops/crops-list" color="whiteDark" label="Browse crops" />
|
||||
</BaseButtons>
|
||||
</div>
|
||||
</CardBox>
|
||||
) : null}
|
||||
|
||||
{!isLoadingCrops && crops.length > 0 ? (
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr,0.9fr]">
|
||||
<CardBox className="mb-6 xl:mb-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-emerald-700">Step 1</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Quick cost log</h2>
|
||||
</div>
|
||||
<div className="rounded-full bg-emerald-100 px-4 py-2 text-sm font-medium text-emerald-700">
|
||||
Mobile-friendly input
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-500">
|
||||
Save one real production expense and optionally update the selling target for the crop in the same action.
|
||||
</p>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FormField label="Crop" labelFor="cropId" help="Choose the crop you are currently tracking.">
|
||||
<select id="cropId" name="cropId" value={formState.cropId} onChange={handleInputChange}>
|
||||
{crops.map((crop) => (
|
||||
<option key={crop.id} value={crop.id}>
|
||||
{crop.crop_name} · {crop.region_name || 'Region pending'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label="Cost type" labelFor="costType">
|
||||
<select id="costType" name="costType" value={formState.costType} onChange={handleInputChange}>
|
||||
<option value="seeds">Seeds</option>
|
||||
<option value="fertilizer">Fertilizer</option>
|
||||
<option value="labor">Labor</option>
|
||||
<option value="transport">Transport</option>
|
||||
<option value="equipment">Equipment</option>
|
||||
<option value="irrigation">Irrigation</option>
|
||||
<option value="storage">Storage</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Entry date" labelFor="entryDate">
|
||||
<input
|
||||
id="entryDate"
|
||||
name="entryDate"
|
||||
type="datetime-local"
|
||||
value={formState.entryDate}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label="Amount" labelFor="amount" help="Use the same currency you want in your farm reports.">
|
||||
<input
|
||||
id="amount"
|
||||
name="amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="25000"
|
||||
value={formState.amount}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Currency" labelFor="currency">
|
||||
<input
|
||||
id="currency"
|
||||
name="currency"
|
||||
placeholder="RWF"
|
||||
value={formState.currency}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Vendor or source" labelFor="vendorName">
|
||||
<input
|
||||
id="vendorName"
|
||||
name="vendorName"
|
||||
placeholder="Input supplier, shop, or labor source"
|
||||
value={formState.vendorName}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
label="Target selling price"
|
||||
labelFor="targetPricePerUnit"
|
||||
help="Optional. If you add it, Ikiguzi updates your active selling threshold."
|
||||
>
|
||||
<input
|
||||
id="targetPricePerUnit"
|
||||
name="targetPricePerUnit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="540"
|
||||
value={formState.targetPricePerUnit}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Unit" labelFor="unitName">
|
||||
<input
|
||||
id="unitName"
|
||||
name="unitName"
|
||||
placeholder="kg"
|
||||
value={formState.unitName}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Notes" labelFor="notes" hasTextareaHeight>
|
||||
<textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
placeholder="Add context such as quality, season, or supplier notes"
|
||||
value={formState.notes}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton type="submit" color="info" label={isSubmitting ? 'Saving…' : 'Save cost entry'} disabled={isSubmitting} />
|
||||
<BaseButton href="/cost_records/cost_records-list" color="whiteDark" label="Open full cost log" />
|
||||
</BaseButtons>
|
||||
</form>
|
||||
</CardBox>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className={`rounded-3xl border p-6 shadow-sm ${recommendationStyle.panel}`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-gray-600">Step 2</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Selling signal</h2>
|
||||
</div>
|
||||
<span className={`rounded-full px-4 py-2 text-sm font-medium ${recommendationStyle.badge}`}>
|
||||
{recommendationTone.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-5 text-2xl font-semibold text-gray-900">{overview?.recommendation?.title || 'Build your signal'}</p>
|
||||
<p className="mt-3 text-sm leading-6 text-gray-600">
|
||||
{overview?.recommendation?.message || 'Select a crop and start logging costs to see market guidance.'}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-2xl bg-white/80 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">Latest market price</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">
|
||||
{overview?.latestPrice
|
||||
? formatMoney(overview.latestPrice.price_per_unit, overview.latestPrice.currency)
|
||||
: 'No live price'}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{overview?.latestPrice
|
||||
? `${overview.latestPrice.market_name || 'Market source'} · ${formatDate(overview.latestPrice.price_date)}`
|
||||
: 'Import market prices to populate this block'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/80 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">Latest AI forecast</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">
|
||||
{overview?.latestPrediction
|
||||
? formatMoney(
|
||||
overview.latestPrediction.predicted_price_per_unit,
|
||||
overview.latestPrediction.currency,
|
||||
)
|
||||
: 'No forecast'}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{overview?.latestPrediction
|
||||
? `${overview.latestPrediction.model_name || 'Model'} · ${overview.latestPrediction.confidence_percent || 0}% confidence`
|
||||
: 'Create or import predictions to compare selling windows'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseButtons className="mt-6" type="justify-start">
|
||||
<BaseButton href="/market_predictions/market_predictions-new" color="info" label="Add AI prediction" />
|
||||
<BaseButton href="/market_prices/market_prices-list" color="whiteDark" label="Check market prices" />
|
||||
</BaseButtons>
|
||||
</div>
|
||||
|
||||
<CardBox>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-emerald-700">Step 3</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Crop snapshot</h2>
|
||||
</div>
|
||||
{isLoadingOverview ? (
|
||||
<span className="rounded-full bg-gray-100 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
|
||||
Refreshing
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-2xl bg-slate-50 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Selected crop</p>
|
||||
<p className="mt-2 text-xl font-semibold text-slate-900">{selectedCrop?.crop_name || 'Select crop'}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{selectedCrop?.region_name || 'Region pending'} · {selectedCrop?.season_name || 'Season not set'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-slate-50 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Tracked area</p>
|
||||
<p className="mt-2 text-xl font-semibold text-slate-900">
|
||||
{selectedCrop?.area_hectares ? `${selectedCrop.area_hectares} ha` : 'Area not set'}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Harvest target: {formatDate((overview?.crop as any)?.expected_harvest_date)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-2xl bg-emerald-50 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700">Cost mix</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(overview?.totals?.breakdown || []).length > 0 ? (
|
||||
overview?.totals?.breakdown?.map((item) => (
|
||||
<div key={item.cost_type} className="rounded-full bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm">
|
||||
{item.cost_type}: {formatMoney(item.total, formState.currency)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-emerald-800">No cost entries yet for this crop.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoadingCrops && crops.length > 0 ? (
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-3">
|
||||
<CardBox>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-2xl bg-emerald-100 p-3 text-emerald-700">
|
||||
<span className="sr-only">Cost records</span>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d={mdiCashMultiple} />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">Recent costs</h3>
|
||||
<p className="text-sm text-gray-500">Last entries for this crop</p>
|
||||
</div>
|
||||
</div>
|
||||
<BaseButton href="/cost_records/cost_records-list" color="whiteDark" label="View all" small />
|
||||
</div>
|
||||
<BaseDivider />
|
||||
<div className="space-y-3">
|
||||
{overview?.recentCostRecords?.length ? (
|
||||
overview.recentCostRecords.map((record) => (
|
||||
<div key={record.id} className="rounded-2xl border border-gray-100 bg-slate-50 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold capitalize text-gray-900">{record.cost_type}</p>
|
||||
<p className="text-sm text-gray-500">{record.vendor_name || 'No vendor provided'}</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-gray-900">{formatMoney(record.amount, record.currency)}</p>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-sm text-gray-500">
|
||||
<span>{formatDate(record.entry_date)}</span>
|
||||
<BaseButton href={`/cost_records/${record.id}`} color="whiteDark" label="Details" small />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="rounded-2xl bg-slate-50 p-4 text-sm text-gray-500">
|
||||
No costs have been logged for this crop yet. Add your first expense to start the timeline.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-2xl bg-sky-100 p-3 text-sky-700">
|
||||
<span className="sr-only">Predictions</span>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d={mdiRobot} />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">AI predictions</h3>
|
||||
<p className="text-sm text-gray-500">Latest forecast snapshots</p>
|
||||
</div>
|
||||
</div>
|
||||
<BaseButton href="/market_predictions/market_predictions-list" color="whiteDark" label="View all" small />
|
||||
</div>
|
||||
<BaseDivider />
|
||||
<div className="space-y-3">
|
||||
{overview?.recentPredictions?.length ? (
|
||||
overview.recentPredictions.map((prediction) => (
|
||||
<div key={prediction.id} className="rounded-2xl border border-gray-100 bg-slate-50 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">{formatMoney(prediction.predicted_price_per_unit, prediction.currency)}</p>
|
||||
<p className="text-sm text-gray-500">{prediction.model_name || 'Model unavailable'}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-white px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">
|
||||
{prediction.confidence_percent || 0}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-sm text-gray-500">
|
||||
<span>{formatDate(prediction.generated_at)}</span>
|
||||
<BaseButton href={`/market_predictions/${prediction.id}`} color="whiteDark" label="Details" small />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="rounded-2xl bg-slate-50 p-4 text-sm text-gray-500">
|
||||
No prediction has been added for this crop yet. Create one to compare with your target selling price.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-2xl bg-amber-100 p-3 text-amber-700">
|
||||
<span className="sr-only">Market prices</span>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d={mdiBellAlert} />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">Market watch</h3>
|
||||
<p className="text-sm text-gray-500">Recent live prices in your crop lane</p>
|
||||
</div>
|
||||
</div>
|
||||
<BaseButton href="/market_prices/market_prices-list" color="whiteDark" label="View all" small />
|
||||
</div>
|
||||
<BaseDivider />
|
||||
<div className="space-y-3">
|
||||
{overview?.recentPrices?.length ? (
|
||||
overview.recentPrices.map((price) => (
|
||||
<div key={price.id} className="rounded-2xl border border-gray-100 bg-slate-50 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">{formatMoney(price.price_per_unit, price.currency)}</p>
|
||||
<p className="text-sm text-gray-500">{price.market_name || price.region_name || 'Source pending'}</p>
|
||||
</div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">{price.unit_name || 'unit'}</p>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-500">{formatDate(price.price_date)}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="rounded-2xl bg-slate-50 p-4 text-sm text-gray-500">
|
||||
No market price record matches this crop yet. Import prices to strengthen the selling signal.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 rounded-3xl border border-emerald-100 bg-white p-6 shadow-sm">
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-emerald-700">What this slice covers</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-gray-900">One thin workflow, fully connected</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-slate-50 p-4">
|
||||
<p className="text-lg font-semibold text-gray-900">Create</p>
|
||||
<p className="mt-2 text-sm text-gray-500">Farmers log a real production cost with only the fields needed for day-one value.</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-slate-50 p-4">
|
||||
<p className="text-lg font-semibold text-gray-900">Confirm + review</p>
|
||||
<p className="mt-2 text-sm text-gray-500">The page refreshes the crop insight, highlights selling guidance, and links into list and detail screens.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
FarmWorkspace.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default FarmWorkspace;
|
||||
@ -1,166 +1,184 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'Track every farm cost',
|
||||
body: 'Capture seeds, fertilizer, labor, transport, and storage in one lightweight workflow that works well on low-end devices.',
|
||||
},
|
||||
{
|
||||
title: 'Read AI selling signals',
|
||||
body: 'Compare the latest prediction, live market price, and your target threshold to decide whether to sell, watch, or wait.',
|
||||
},
|
||||
{
|
||||
title: 'Work with clean records',
|
||||
body: 'Move from quick entry to lists, detail pages, and reports without rebuilding the generic CRUD foundation already in the app.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('video');
|
||||
const [contentPosition, setContentPosition] = useState('right');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
|
||||
const title = 'Ikiguzi Price Tracker'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
const workflowSteps = [
|
||||
{
|
||||
step: '01',
|
||||
title: 'Register a crop',
|
||||
body: 'Set up the crop, region, season, and area so every cost and prediction has a real farming context.',
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
title: 'Log costs fast',
|
||||
body: 'Use the farm workspace to add a cost entry and optionally update the crop’s target selling price in one action.',
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
title: 'Act on the signal',
|
||||
body: 'Review the recommendation, check recent market prices, and open the admin workflow for deeper records and follow-up.',
|
||||
},
|
||||
];
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Ikiguzi')}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Ikiguzi helps Rwandan farmers track production costs, monitor market prices, and prepare better selling decisions."
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your Ikiguzi Price Tracker app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(16,185,129,0.18),_transparent_36%),linear-gradient(180deg,_#F5FFFB_0%,_#F8FAFC_55%,_#FFFFFF_100%)] text-slate-900">
|
||||
<header className="sticky top-0 z-20 border-b border-white/70 bg-white/80 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-emerald-700">Ikiguzi</p>
|
||||
<p className="text-sm text-slate-500">AI-powered crop cost & market price tracker</p>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
|
||||
<BaseButtons type="justify-end" noWrap>
|
||||
<BaseButton href="/dashboard" color="whiteDark" label="Admin interface" />
|
||||
<BaseButton href="/login" color="info" label="Login" />
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
</div>
|
||||
<main>
|
||||
<section className="mx-auto grid max-w-7xl gap-8 px-6 pb-12 pt-12 lg:grid-cols-[1.2fr,0.8fr] lg:items-center lg:pb-20 lg:pt-16">
|
||||
<div>
|
||||
<div className="mb-4 inline-flex rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800">
|
||||
Built for smallholder farmers, cooperatives, and extension teams
|
||||
</div>
|
||||
<h1 className="max-w-4xl text-5xl font-bold leading-tight tracking-tight text-slate-950 md:text-6xl">
|
||||
Turn daily farm expenses into clear, confident selling decisions.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
|
||||
Ikiguzi combines crop cost tracking, market monitoring, and simple AI guidance so farmers can understand
|
||||
what they spent, what the market is doing, and when it may be the right time to sell.
|
||||
</p>
|
||||
<BaseButtons className="mt-8" type="justify-start">
|
||||
<BaseButton href="/login" color="info" label="Start with login" />
|
||||
<BaseButton href="/dashboard" color="whiteDark" label="Open admin interface" />
|
||||
</BaseButtons>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="overflow-hidden rounded-3xl bg-gradient-to-br from-emerald-950 via-emerald-800 to-sky-600 p-6 text-white shadow-2xl">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-emerald-100/80">First MVP slice</p>
|
||||
<p className="mt-4 text-3xl font-semibold">Farm workspace</p>
|
||||
<p className="mt-3 text-sm leading-7 text-emerald-50/90">
|
||||
One practical screen for logging costs, updating a target price, and seeing a sell / watch / hold signal.
|
||||
</p>
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-2xl bg-white/10 p-4 backdrop-blur">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-100/80">Inputs</p>
|
||||
<p className="mt-2 text-lg font-semibold">Crop + cost + target</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/10 p-4 backdrop-blur">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-100/80">Signal</p>
|
||||
<p className="mt-2 text-lg font-semibold">AI + market + threshold</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/10 p-4 backdrop-blur">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-100/80">Follow-up</p>
|
||||
<p className="mt-2 text-lg font-semibold">Lists + detail pages</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-3xl border border-emerald-100 bg-white p-5 shadow-sm">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-emerald-700">Why it matters</p>
|
||||
<p className="mt-3 text-lg font-semibold text-slate-900">Low-friction input for low-literacy contexts</p>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-600">
|
||||
The workflow keeps labels simple, focuses on the minimum useful fields, and offers clear next actions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-sky-100 bg-white p-5 shadow-sm">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-sky-700">What stays protected</p>
|
||||
<p className="mt-3 text-lg font-semibold text-slate-900">Public landing, private farm data</p>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-600">
|
||||
The homepage stays public, while crop records, alerts, and reports remain inside the authenticated admin interface.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-7xl px-6 pb-8">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{features.map((feature) => (
|
||||
<div key={feature.title} className="rounded-3xl border border-slate-100 bg-white p-6 shadow-sm">
|
||||
<p className="text-xl font-semibold text-slate-900">{feature.title}</p>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-600">{feature.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-7xl px-6 py-10">
|
||||
<div className="mb-8 max-w-2xl">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">How the first delivery works</p>
|
||||
<h2 className="mt-3 text-4xl font-bold tracking-tight text-slate-950">A real end-to-end workflow, not just a landing page</h2>
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{workflowSteps.map((item) => (
|
||||
<div key={item.step} className="rounded-3xl border border-slate-100 bg-white p-6 shadow-sm">
|
||||
<div className="inline-flex rounded-full bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
|
||||
Step {item.step}
|
||||
</div>
|
||||
<p className="mt-4 text-2xl font-semibold text-slate-900">{item.title}</p>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-600">{item.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-7xl px-6 pb-16">
|
||||
<div className="rounded-[2rem] bg-slate-950 px-8 py-10 text-white shadow-2xl">
|
||||
<div className="grid gap-6 lg:grid-cols-[1.2fr,0.8fr] lg:items-center">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-300">Ready for the admin flow?</p>
|
||||
<h2 className="mt-3 text-4xl font-bold tracking-tight">Go from public overview to the working app.</h2>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-300">
|
||||
Use the admin interface to register crops, log costs, review market predictions, manage notifications,
|
||||
and continue shaping Ikiguzi into a field-ready product.
|
||||
</p>
|
||||
</div>
|
||||
<BaseButtons type="justify-start" className="lg:justify-end">
|
||||
<BaseButton href="/dashboard" color="info" label="Open admin interface" />
|
||||
<BaseButton href="/login" color="whiteDark" label="Login" />
|
||||
</BaseButtons>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user