This commit is contained in:
2026-04-01 17:28:05 +03:30
parent 1d4080a8f5
commit bde110868a
18 changed files with 2679 additions and 1002 deletions
@@ -0,0 +1,134 @@
# Crop Zoning API Integration
## نحوه ارتباط با API
### 1. دریافت اطلاعات Area و Zones
```typescript
GET /api/crop-zoning/area/?sensor_uuid=<sensor_uuid>
```
این endpoint به صورت task-based کار می‌کند:
**مراحل:**
1. اولین بار که API را صدا می‌زنید، task ساخته می‌شود و `status: 'PENDING'` برمی‌گرداند
2. باید هر 2 ثانیه polling کنید تا `status` به `'SUCCESS'` برسد
3. در حین polling، از فیلدهای زیر برای نمایش progress استفاده کنید:
- `task.progress_percent`: درصد پیشرفت (0-100)
- `task.message`: پیام فارسی برای نمایش
- `task.completed_zones` / `task.total_zones`: تعداد زون‌های پردازش شده
**مثال پاسخ در حال پردازش:**
```json
{
"status": "success",
"data": {
"task": {
"status": "PROCESSING",
"stage_label": "در حال پردازش زون‌ها",
"total_zones": 364,
"completed_zones": 182,
"progress_percent": 50,
"message": "از مجموع 364 زون، 182 زون پردازش شده..."
},
"area": { ... },
"zones": []
}
}
```
**مثال پاسخ موفق:**
```json
{
"status": "success",
"data": {
"task": {
"status": "SUCCESS",
"progress_percent": 100,
"total_zones": 364,
"completed_zones": 364
},
"area": {
"type": "Feature",
"geometry": { "type": "Polygon", "coordinates": [...] }
},
"zones": [
{
"zoneId": "zone-0",
"geometry": { "type": "Polygon", "coordinates": [...] },
"crop": "wheat",
"matchPercent": 89,
"waterNeed": "4820-5820 m³/ha"
}
]
}
}
```
### 2. نمایش Progress Bar در UI
```tsx
// وقتی task در حال پردازش است
{loading && progress && (
<Box>
<Typography>{progress.message}</Typography>
<LinearProgress variant="determinate" value={progress.percent} />
<Typography>{progress.percent}%</Typography>
</Box>
)}
```
### 3. وضعیت‌های Task
- `IDLE`: هنوز task ساخته نشده
- `PENDING`: task ساخته شده، منتظر شروع
- `PROCESSING`: در حال پردازش زون‌ها
- `SUCCESS`: همه زون‌ها پردازش شدند
- `FAILURE`: خطا در پردازش
### 4. Flow کامل در کد
```typescript
const loadArea = async () => {
let polls = 0;
while (polls < MAX_POLLS) {
const res = await cropZoningService.getArea(sensorUuid);
const task = res.task;
// نمایش progress
if (task) {
setProgress({
message: task.message || task.stage_label,
percent: task.progress_percent || 0
});
}
// بررسی وضعیت
if (task?.status === 'SUCCESS') {
setAreaGeoJson(res.area);
setZonesData(res.zones);
break;
}
if (task?.status === 'FAILURE') {
throw new Error(task.message);
}
// اگر در حال پردازش است، صبر کن و دوباره بررسی کن
if (task?.status === 'PENDING' || task?.status === 'PROCESSING') {
await sleep(2000);
polls++;
} else {
break;
}
}
};
```
## نکات مهم
1. **Polling Interval**: 2 ثانیه (نه کمتر، نه بیشتر)
2. **Max Attempts**: حداکثر 100 بار تلاش (200 ثانیه = 3.3 دقیقه)
3. **Progress Bar**: حتما از `LinearProgress` با `variant="determinate"` استفاده کنید
4. **Sensor UUID**: باید از props یا context دریافت شود (فعلاً hardcode شده)
@@ -108,6 +108,11 @@ export default function CropZoningMap({
geoJsonLayer.addTo(map)
zonesLayerRef.current = geoJsonLayer
const bounds = geoJsonLayer.getBounds()
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [24, 24] })
}
let idx = 0
geoJsonLayer.eachLayer((layer: L.Layer) => {
const leafLayer = layer as L.Polygon
@@ -183,6 +188,10 @@ export default function CropZoningMap({
drawnItems.clearLayers()
L.geoJSON(initialAreaGeoJson as unknown as Feature<Polygon>).eachLayer((layer) => drawnItems.addLayer(layer))
emitAreaChange()
const bounds = drawnItems.getBounds()
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [24, 24] })
}
}
const onCreated = (e: L.LeafletEvent) => {
@@ -1,303 +1,211 @@
'use client'
"use client";
import { useState, useCallback, useEffect, useMemo } from 'react'
import dynamic from 'next/dynamic'
import { useTranslations } from 'next-intl'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import Button from '@mui/material/Button'
import CropZoningMap from './CropZoningMap'
import ZoneLegend from './ZoneLegend'
import LayerControl from './LayerControl'
import ZoneDetailPanel from './ZoneDetailPanel'
import CropZoningWeatherSection from './CropZoningWeatherSection'
import { createGridFromPolygon } from './cropZoningUtils'
import { useState, useCallback, useEffect, useMemo } from "react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import { useSensorHub } from "@/hooks/useSensorHub";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import LinearProgress from "@mui/material/LinearProgress";
import CropZoningMap from "./CropZoningMap";
import ZoneLegend from "./ZoneLegend";
import LayerControl from "./LayerControl";
import ZoneDetailPanel from "./ZoneDetailPanel";
import CropZoningWeatherSection from "./CropZoningWeatherSection";
import {
cropZoningService,
type Product,
type ZoneInitialData,
type ZoneDetailData,
type ZoneMapData,
type ZoneWaterNeedData,
type ZoneSoilQualityData,
type ZoneCultivationRiskData
} from '@/libs/api/services/cropZoningService'
import { CROP_COLORS, type CropType } from './cropZoningTypes'
import type { LayerType } from './cropZoningTypes'
import type { MapDrawGeoJSON } from './CropZoningMap'
} from "@/libs/api/services/cropZoningService";
import { CROP_COLORS, type CropType } from "./cropZoningTypes";
import type { LayerType } from "./cropZoningTypes";
import type { MapDrawGeoJSON } from "./CropZoningMap";
const MapComponent = dynamic(() => Promise.resolve(CropZoningMap), {
ssr: false,
loading: () => (
<Box className='flex items-center justify-center bs-full min-bs-[400px] rounded-xl bg-action-hover'>
<Box className="flex items-center justify-center bs-full min-bs-[400px] rounded-xl bg-action-hover">
<CircularProgress size={48} />
</Box>
)
})
),
});
function isPolygon(geojson: MapDrawGeoJSON): geojson is MapDrawGeoJSON & { geometry: { type: 'Polygon'; coordinates: unknown[] } } {
return !!geojson?.geometry && (geojson.geometry as { type: string }).type === 'Polygon'
}
const POLL_INTERVAL = 2000;
const MAX_POLLS = 100;
const getNormalizedTaskStatus = (status?: string) => status?.toLowerCase();
export default function CropZoningWrapper() {
const t = useTranslations('cropZoning')
const [areaGeoJson, setAreaGeoJson] = useState<MapDrawGeoJSON>(null)
const [areaLoading, setAreaLoading] = useState(true)
const [zonesData, setZonesData] = useState<ZoneInitialData[] | null>(null)
const [zonesWaterNeed, setZonesWaterNeed] = useState<ZoneWaterNeedData[] | null>(null)
const [zonesSoilQuality, setZonesSoilQuality] = useState<ZoneSoilQualityData[] | null>(null)
const [zonesCultivationRisk, setZonesCultivationRisk] = useState<ZoneCultivationRiskData[] | null>(null)
const [products, setProducts] = useState<Product[]>([])
const [productsLoading, setProductsLoading] = useState(true)
const [zonesLoading, setZonesLoading] = useState(false)
const [layerDataLoading, setLayerDataLoading] = useState(false)
const [activeLayer, setActiveLayer] = useState<LayerType>('crops')
const [selectedZone, setSelectedZone] = useState<ZoneDetailData | null>(null)
const [panelOpen, setPanelOpen] = useState(false)
const [zoneDetailLoading, setZoneDetailLoading] = useState(false)
const [optimizationKey, setOptimizationKey] = useState(0)
const productLabels = Object.fromEntries(products.map(p => [p.id, p.label]))
const t = useTranslations("cropZoning");
const { sensorHub } = useSensorHub();
const [areaGeoJson, setAreaGeoJson] = useState<MapDrawGeoJSON>(null);
const [isClientReady, setIsClientReady] = useState(false);
const [loading, setLoading] = useState(true);
const [progress, setProgress] = useState<{ message: string; percent: number } | null>(null);
const [error, setError] = useState<string | null>(null);
const [zonesData, setZonesData] = useState<ZoneInitialData[] | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [activeLayer, setActiveLayer] = useState<LayerType>("crops");
const [selectedZone, setSelectedZone] = useState<ZoneDetailData | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
useEffect(() => {
cropZoningService
.getProducts()
setIsClientReady(true);
}, []);
useEffect(() => {
cropZoningService.getProducts()
.then(res => setProducts(res.products))
.catch(() => setProducts([]))
.finally(() => setProductsLoading(false))
}, [])
.catch(() => setProducts([]));
}, []);
useEffect(() => {
setAreaLoading(true)
cropZoningService
.getArea()
.then(res => setAreaGeoJson(res.area as unknown as MapDrawGeoJSON))
.catch(() => setAreaGeoJson(null))
.finally(() => setAreaLoading(false))
}, [])
let cancelled = false;
const fetchZones = useCallback((geojson: MapDrawGeoJSON) => {
if (!isPolygon(geojson)) {
setZonesData(null)
setZonesWaterNeed(null)
setZonesSoilQuality(null)
setZonesCultivationRisk(null)
return
}
setZonesWaterNeed(null)
setZonesSoilQuality(null)
setZonesCultivationRisk(null)
setZonesLoading(true)
const grid = createGridFromPolygon(geojson as unknown as import('geojson').Feature<import('geojson').Polygon>)
cropZoningService
.getZonesInitial({ zones: grid })
.then(res => setZonesData(res.zones))
.catch(() => setZonesData(null))
.finally(() => setZonesLoading(false))
}, [])
const loadArea = async () => {
if (!isClientReady) return;
useEffect(() => {
if (isPolygon(areaGeoJson)) {
fetchZones(areaGeoJson)
} else {
setZonesData(null)
setZonesWaterNeed(null)
setZonesSoilQuality(null)
setZonesCultivationRisk(null)
}
}, [areaGeoJson, optimizationKey, fetchZones])
if (!sensorHub?.id) {
setError(t("errors.noSensor"));
setLoading(false);
return;
}
const gridForLayers = isPolygon(areaGeoJson)
? createGridFromPolygon(areaGeoJson as unknown as import('geojson').Feature<import('geojson').Polygon>)
: null
setLoading(true);
setError(null);
setProgress({ message: t("loadingArea"), percent: 0 });
useEffect(() => {
if (!gridForLayers || zonesLoading) return
if (activeLayer === 'waterNeed' && zonesWaterNeed === null) {
setLayerDataLoading(true)
cropZoningService
.getZonesWaterNeed({ zones: gridForLayers })
.then(res => setZonesWaterNeed(res.zones))
.catch(() => setZonesWaterNeed(null))
.finally(() => setLayerDataLoading(false))
} else if (activeLayer === 'soilQuality' && zonesSoilQuality === null) {
setLayerDataLoading(true)
cropZoningService
.getZonesSoilQuality({ zones: gridForLayers })
.then(res => setZonesSoilQuality(res.zones))
.catch(() => setZonesSoilQuality(null))
.finally(() => setLayerDataLoading(false))
} else if (activeLayer === 'cultivationRisk' && zonesCultivationRisk === null) {
setLayerDataLoading(true)
cropZoningService
.getZonesCultivationRisk({ zones: gridForLayers })
.then(res => setZonesCultivationRisk(res.zones))
.catch(() => setZonesCultivationRisk(null))
.finally(() => setLayerDataLoading(false))
}
}, [activeLayer, gridForLayers, zonesLoading, zonesWaterNeed, zonesSoilQuality, zonesCultivationRisk])
try {
let polls = 0;
while (!cancelled && polls < MAX_POLLS) {
const res = await cropZoningService.getArea(sensorHub.id);
const mapZonesData = useMemo((): ZoneMapData[] | null => {
const labels = Object.fromEntries(products.map(p => [p.id, p.label]))
if (activeLayer === 'crops' && zonesData) {
const isCultivable = (crop: string | null | undefined) =>
!!crop && crop !== 'uncultivable' && crop.toLowerCase() !== 'uncultivable'
return zonesData.map(z => {
const cultivable = isCultivable(z.crop)
const cropLabel = cultivable ? (labels[z.crop!] ?? z.crop) : 'غیر قابل کشت'
const tooltipContent = cultivable
? `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
<div style="font-weight: 600; margin-bottom: 6px;">${cropLabel}</div>
<div>درصد تطابق: ${z.matchPercent ?? '-'}%</div>
<div>نیاز آب: ${z.waterNeed ?? '-'}</div>
<div>سود تخمینی: ${z.estimatedProfit ?? '-'}</div>
</div>`
: `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
<div style="font-weight: 600; margin-bottom: 6px; color: #64748b;">غیر قابل کشت</div>
<div style="color: #94a3b8;">این بخش برای کشت مناسب تشخیص داده نشده است.</div>
</div>`
const color = cultivable ? (CROP_COLORS[z.crop as CropType] ?? '#94a3b8') : '#94a3b8'
return {
zoneId: z.zoneId,
geometry: z.geometry,
color,
tooltipContent,
cultivable,
zoneInitialData: z
if (!("area" in res)) break;
const task = res.task;
const taskStatus = getNormalizedTaskStatus(task?.status);
if (task) {
setProgress({
message: task.message || task.stage_label || t("loadingArea"),
percent: task.progress_percent || 0,
});
}
if (taskStatus === "completed" || taskStatus === "success") {
setAreaGeoJson(res.area as unknown as MapDrawGeoJSON);
if (res.zones) setZonesData(res.zones as ZoneInitialData[]);
break;
}
if ((!task && res.area) || (res.area && taskStatus !== "pending" && taskStatus !== "processing")) {
setAreaGeoJson(res.area as unknown as MapDrawGeoJSON);
if (res.zones) setZonesData(res.zones as ZoneInitialData[]);
break;
}
if (taskStatus === "failed" || taskStatus === "failure") {
throw new Error(task.message || t("errors.areaLoadFailed"));
}
if (taskStatus === "pending" || taskStatus === "processing") {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
polls++;
} else {
break;
}
}
})
}
if (activeLayer === 'waterNeed' && zonesWaterNeed) {
const levelLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد' }
return zonesWaterNeed.map(z => ({
zoneId: z.zoneId,
geometry: z.geometry,
color: z.color,
tooltipContent: `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
<div style="font-weight: 600; margin-bottom: 6px;">نیاز آبی: ${levelLabels[z.level]}</div>
<div>${z.value ?? '-'}</div>
</div>`,
cultivable: true,
zoneInitialData: { zoneId: z.zoneId, geometry: z.geometry } as ZoneInitialData
}))
}
if (activeLayer === 'soilQuality' && zonesSoilQuality) {
const levelLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد' }
return zonesSoilQuality.map(z => ({
zoneId: z.zoneId,
geometry: z.geometry,
color: z.color,
tooltipContent: `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
<div style="font-weight: 600; margin-bottom: 6px;">کیفیت خاک: ${levelLabels[z.level]}</div>
<div>امتیاز: ${z.score ?? '-'}</div>
</div>`,
cultivable: true,
zoneInitialData: { zoneId: z.zoneId, geometry: z.geometry } as ZoneInitialData
}))
}
if (activeLayer === 'cultivationRisk' && zonesCultivationRisk) {
const levelLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد' }
return zonesCultivationRisk.map(z => ({
zoneId: z.zoneId,
geometry: z.geometry,
color: z.color,
tooltipContent: `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
<div style="font-weight: 600; margin-bottom: 6px;">ریسک کشت: ${levelLabels[z.level]}</div>
</div>`,
cultivable: true,
zoneInitialData: { zoneId: z.zoneId, geometry: z.geometry } as ZoneInitialData
}))
}
return null
}, [activeLayer, zonesData, zonesWaterNeed, zonesSoilQuality, zonesCultivationRisk, products])
if (polls >= MAX_POLLS) {
throw new Error(t("errors.timeout"));
}
} catch (err) {
setError(err instanceof Error ? err.message : t("errors.areaLoadFailed"));
} finally {
if (!cancelled) {
setLoading(false);
setProgress(null);
}
}
};
const handleAreaChange = useCallback((geojson: MapDrawGeoJSON) => {
setAreaGeoJson(geojson)
}, [])
loadArea();
return () => { cancelled = true; };
}, [isClientReady, sensorHub, t]);
const mapZonesData = useMemo(() => {
if (activeLayer === "crops" && zonesData) {
return zonesData.map(z => ({
zoneId: z.zoneId,
geometry: z.geometry,
color: z.crop ? CROP_COLORS[z.crop as CropType] || "#94a3b8" : "#94a3b8",
tooltipContent: `<div style="padding: 4px 8px;">${z.crop || "نامشخص"}</div>`,
cultivable: !!z.crop,
zoneInitialData: z,
}));
}
return null;
}, [activeLayer, zonesData]);
const handleZoneClick = useCallback((zone: ZoneInitialData) => {
setZoneDetailLoading(true)
setPanelOpen(true)
setSelectedZone(null)
cropZoningService
.getZoneDetails(zone.zoneId)
.then(details => setSelectedZone(details))
.catch(() => setSelectedZone(null))
.finally(() => setZoneDetailLoading(false))
}, [])
const handleOptimize = useCallback(() => {
setOptimizationKey(k => k + 1)
}, [])
setPanelOpen(true);
setSelectedZone(null);
cropZoningService.getZoneDetails(zone.zoneId)
.then(setSelectedZone)
.catch(() => setSelectedZone(null));
}, []);
return (
<Box className='flex flex-col gap-6 is-full'>
<Box className='relative min-bs-[400px] rounded-xl overflow-hidden' sx={{ height: 'min(60vh, 500px)' }}>
<Box className='absolute inset-0 z-0'>
<Box className="flex flex-col gap-6 is-full">
<Box className="relative min-bs-[400px] rounded-xl overflow-hidden" sx={{ height: "min(60vh, 500px)" }}>
<Box className="absolute inset-0 z-0">
{areaGeoJson ? (
<MapComponent
key='crop-zoning-map'
center={[35.6892, 51.389]}
zoom={13}
height='100%'
height="100%"
activeLayer={activeLayer}
onAreaChange={handleAreaChange}
onZoneClick={handleZoneClick}
optimizationKey={optimizationKey}
className='min-bs-[400px]'
initialAreaGeoJson={areaGeoJson}
zonesData={mapZonesData}
productLabels={productLabels}
productLabels={Object.fromEntries(products.map(p => [p.id, p.label]))}
readOnly
/>
) : (
<Box className='flex items-center justify-center bs-full min-bs-[400px] rounded-xl bg-action-hover' />
<Box className="flex items-center justify-center bs-full bg-action-hover" />
)}
</Box>
{(areaLoading || zonesLoading || (activeLayer !== 'crops' && layerDataLoading)) && (
<Box
className='absolute inset-0 z-[500] flex items-center justify-center bg-white/60 dark:bg-gray-900/60'
sx={{ borderRadius: 12 }}
>
{loading && (
<Box className="absolute inset-0 z-[500] flex flex-col items-center justify-center gap-3 bg-white/80 dark:bg-gray-900/80" sx={{ borderRadius: 12 }}>
<CircularProgress size={48} />
{progress && (
<Box className="w-full max-w-md px-8">
<Box sx={{ fontSize: 14, fontWeight: 500, mb: 1, textAlign: "center" }}>
{progress.message}
</Box>
<LinearProgress variant="determinate" value={progress.percent} sx={{ height: 8, borderRadius: 4 }} />
<Box sx={{ fontSize: 12, mt: 0.5, textAlign: "center", color: "text.secondary" }}>
{progress.percent}%
</Box>
</Box>
)}
</Box>
)}
{error && !loading && (
<Box className="absolute inset-x-4 bottom-4 z-[600] rounded-xl px-4 py-3 text-sm" sx={{ backgroundColor: "error.main", color: "error.contrastText" }}>
{error}
</Box>
)}
<LayerControl activeLayer={activeLayer} onLayerChange={setActiveLayer} />
<ZoneLegend
activeLayer={activeLayer}
products={products}
loading={productsLoading || (activeLayer !== 'crops' && layerDataLoading)}
/>
{areaGeoJson && (
<Box className='absolute top-16 end-4 z-[1000]'>
<Button
variant='contained'
color='primary'
size='medium'
startIcon={<i className='tabler-refresh text-xl' />}
onClick={handleOptimize}
disabled={zonesLoading}
className='rounded-xl shadow-lg'
>
{t('optimizeAgain')}
</Button>
</Box>
)}
<ZoneLegend activeLayer={activeLayer} products={products} loading={false} />
</Box>
<ZoneDetailPanel
open={panelOpen}
onClose={() => setPanelOpen(false)}
zone={selectedZone}
products={products}
loading={zoneDetailLoading}
/>
<ZoneDetailPanel open={panelOpen} onClose={() => setPanelOpen(false)} zone={selectedZone} products={products} loading={false} />
<CropZoningWeatherSection />
</Box>
)
);
}
@@ -0,0 +1,137 @@
'use client'
import { useTranslations } from 'next-intl'
import Box from '@mui/material/Box'
import IconButton from '@mui/material/IconButton'
import Typography from '@mui/material/Typography'
import List from '@mui/material/List'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemText from '@mui/material/ListItemText'
import ListItemIcon from '@mui/material/ListItemIcon'
import CircularProgress from '@mui/material/CircularProgress'
import { useTheme, alpha } from '@mui/material/styles'
import type { ConversationSummary } from './farmAiAssistantTypes'
interface ChatSidebarProps {
open: boolean
loading: boolean
conversations: ConversationSummary[]
activeConversationId: string | null
onClose: () => void
onSelectConversation: (id: string) => void
getConversationLabel: (conversation: ConversationSummary, index: number) => string
}
export default function ChatSidebar({
open,
loading,
conversations,
activeConversationId,
onClose,
onSelectConversation,
getConversationLabel
}: ChatSidebarProps) {
const t = useTranslations('farmAiAssistant')
const theme = useTheme()
return (
<>
<Box
onClick={onClose}
sx={{
position: 'absolute',
inset: 0,
bgcolor: alpha(theme.palette.common.black, 0.18),
opacity: open ? 1 : 0,
pointerEvents: open ? 'auto' : 'none',
transition: 'opacity 0.24s ease',
zIndex: 5
}}
/>
<Box
sx={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: { xs: '88%', sm: 320 },
maxWidth: 320,
transform: open ? 'translateX(0)' : 'translateX(-104%)',
transition: 'transform 0.28s ease',
zIndex: 6,
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.paper',
borderInlineEnd: `1px solid ${alpha(theme.palette.primary.main, 0.12)}`,
boxShadow: `0 10px 30px ${alpha(theme.palette.common.black, 0.12)}`
}}
>
<Box
className='flex items-center justify-between px-4 py-3'
sx={{ borderBottom: `1px solid ${alpha(theme.palette.primary.main, 0.12)}` }}
>
<Typography variant='subtitle1' fontWeight={700}>
{t('sidebar.title')}
</Typography>
<IconButton size='small' onClick={onClose}>
<i className='tabler-x text-xl' />
</IconButton>
</Box>
<Box className='flex-1 overflow-y-auto py-2'>
{loading ? (
<Box className='flex justify-center py-8'>
<CircularProgress size={28} />
</Box>
) : conversations.length === 0 ? (
<Box className='flex flex-col items-center justify-center py-8 px-4 text-center'>
<i
className='tabler-messages text-4xl mb-2'
style={{ color: alpha(theme.palette.text.secondary, 0.35) }}
/>
<Typography variant='body2' color='text.secondary'>
{t('sidebar.empty')}
</Typography>
</Box>
) : (
<List disablePadding>
{conversations.map((conversation, index) => (
<ListItemButton
key={conversation.id}
selected={conversation.id === activeConversationId}
onClick={() => onSelectConversation(conversation.id)}
sx={{
mx: 1,
my: 0.5,
borderRadius: '12px',
'&.Mui-selected': {
backgroundColor: alpha(theme.palette.primary.main, 0.1),
'&:hover': {
backgroundColor: alpha(theme.palette.primary.main, 0.15)
}
}
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<i className='tabler-message text-lg' style={{ color: theme.palette.primary.main }} />
</ListItemIcon>
<ListItemText
primary={getConversationLabel(conversation, index)}
primaryTypographyProps={{
variant: 'body2',
fontWeight: conversation.id === activeConversationId ? 700 : 500,
noWrap: true
}}
secondary={`${conversation.message_count} پیام`}
secondaryTypographyProps={{ variant: 'caption' }}
/>
</ListItemButton>
))}
</List>
)}
</Box>
</Box>
</>
)
}
@@ -7,6 +7,7 @@ import Box from '@mui/material/Box'
import Typography from '@mui/material/Typography'
import Card from '@mui/material/Card'
import IconButton from '@mui/material/IconButton'
import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'
import Collapse from '@mui/material/Collapse'
import { useTheme } from '@mui/material/styles'
@@ -17,7 +18,8 @@ import classnames from 'classnames'
import { commonLayoutClasses } from '@layouts/utils/layoutClasses'
import { farmAiAssistantService } from '@/libs/api/services/farmAiAssistantService'
import type { FarmContext, FarmAIMessage, AIResponseSection } from './farmAiAssistantTypes'
import type { ConversationSummary, FarmContext, FarmAIMessage, AIResponseSection } from './farmAiAssistantTypes'
import ChatSidebar from './ChatSidebar'
// ─── Constants ─────────────────────────────────────────────────────────────
@@ -36,6 +38,8 @@ const SUGGESTION_CHIPS = [
{ id: 'plant-disease', labelKey: 'suggestions.plantDisease' }
]
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
// ─── Main Component ────────────────────────────────────────────────────────
export default function FarmAiAssistantChat() {
@@ -50,10 +54,46 @@ export default function FarmAiAssistantChat() {
const [farmContext, setFarmContext] = useState<FarmContext>(DEFAULT_FARM_CONTEXT)
const [contextLoading, setContextLoading] = useState(true)
const [conversationId, setConversationId] = useState<string | null>(null)
const [conversations, setConversations] = useState<ConversationSummary[]>([])
const [conversationLoading, setConversationLoading] = useState(false)
const [sidebarOpen, setSidebarOpen] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const { primary, info, warning } = theme.palette
const mapApiMessageToUi = (message: {
message_id: string
role: 'user' | 'assistant'
content?: string
sections?: AIResponseSection[]
images?: string[]
created_at?: string
}): FarmAIMessage => ({
id: message.message_id,
role: message.role,
content: message.content ?? '',
timestamp: new Date(message.created_at ?? Date.now()),
images: message.images ?? [],
sections: message.sections ?? []
})
const loadConversations = async () => {
setConversationLoading(true)
try {
const data = await farmAiAssistantService.getConversations()
setConversations(data)
} catch {
toast.error(t('errors.conversationLoad'))
} finally {
setConversationLoading(false)
}
}
const getConversationLabel = (conversation: ConversationSummary, index: number) => {
if (conversation.title?.trim()) return conversation.title
return `${t('sidebar.chatLabel')} ${index + 1}`
}
// Fetch farm context on mount
useEffect(() => {
let cancelled = false
@@ -83,6 +123,16 @@ export default function FarmAiAssistantChat() {
}
}, [t])
useEffect(() => {
loadConversations()
}, [])
useEffect(() => {
if (sidebarOpen) {
loadConversations()
}
}, [sidebarOpen])
// Scroll to bottom on new messages
useEffect(() => {
scrollRef.current?.scrollTo({
@@ -114,20 +164,39 @@ export default function FarmAiAssistantChat() {
setIsTyping(true)
try {
const res = await farmAiAssistantService.chat({
const task = await farmAiAssistantService.createChatTask({
content,
title: !conversationId ? content.slice(0, 60) : undefined,
farm_context: farmContext,
...(conversationId ? { conversation_id: conversationId } : {})
})
if (res.conversation_id) setConversationId(res.conversation_id)
const aiMessage: FarmAIMessage = {
id: res.message_id,
role: 'assistant',
content: res.content ?? '',
timestamp: new Date(),
sections: res.sections ?? []
if (task.conversation_id) {
setConversationId(task.conversation_id)
}
setMessages(prev => [...prev, aiMessage])
let attempts = 0
let taskStatus = await farmAiAssistantService.getChatTaskStatus(task.task_id)
while (taskStatus.status === 'PENDING' || taskStatus.status === 'STARTED') {
attempts += 1
if (attempts >= 20) {
throw new Error('timeout')
}
await sleep(1500)
taskStatus = await farmAiAssistantService.getChatTaskStatus(task.task_id)
}
if (taskStatus.status === 'FAILURE' || !taskStatus.result) {
throw new Error(taskStatus.error || 'chat-failed')
}
const result = taskStatus.result
setMessages(prev => [...prev, mapApiMessageToUi(result)])
await loadConversations()
} catch {
toast.error(t('errors.chatSend'))
} finally {
@@ -135,6 +204,45 @@ export default function FarmAiAssistantChat() {
}
}
const handleNewChat = async () => {
setConversationId(null)
setMessages([])
setSelectedChip(null)
setExpandedExplanations(new Set())
setInputValue('')
setSidebarOpen(false)
try {
const conversation = await farmAiAssistantService.createConversation({
title: t('sidebar.newChat'),
farm_context: farmContext
})
setConversationId(conversation.id)
await loadConversations()
} catch {
setConversationId(null)
toast.error(t('errors.conversationCreate'))
}
}
const handleSelectConversation = async (id: string) => {
setConversationId(id)
setMessages([])
setIsTyping(true)
setSidebarOpen(false)
try {
const data = await farmAiAssistantService.getConversationMessages(id)
setMessages(data.messages.map(mapApiMessageToUi))
} catch {
toast.error(t('errors.conversationLoad'))
} finally {
setIsTyping(false)
}
}
const toggleExplanation = (id: string) => {
setExpandedExplanations(prev => {
const next = new Set(prev)
@@ -149,10 +257,21 @@ export default function FarmAiAssistantChat() {
className={classnames(commonLayoutClasses.contentHeightFixed, 'flex flex-col is-full overflow-hidden rounded')}
sx={{
background: `linear-gradient(180deg, ${theme.palette.background.default} 0%, ${alpha(primary.main, 0.04)} 30%, ${alpha(primary.main, 0.08)} 100%)`,
minHeight: '100%'
minHeight: '100%',
position: 'relative'
}}
>
{/* 1) Smart Header */}
<ChatSidebar
open={sidebarOpen}
loading={conversationLoading}
conversations={conversations}
onClose={() => setSidebarOpen(false)}
activeConversationId={conversationId}
onSelectConversation={handleSelectConversation}
getConversationLabel={getConversationLabel}
/>
<Box
className='flex items-center gap-3 px-4 pt-4 pb-3 flex-shrink-0'
sx={{
@@ -160,6 +279,33 @@ export default function FarmAiAssistantChat() {
borderBottom: `1px solid ${alpha(primary.main, 0.12)}`
}}
>
<Box className='flex items-center gap-2 shrink-0'>
<IconButton
size='small'
onClick={() => setSidebarOpen(prev => !prev)}
sx={{
borderRadius: '12px',
bgcolor: alpha(primary.main, 0.08),
'&:hover': { bgcolor: alpha(primary.main, 0.16) }
}}
>
<i className='tabler-menu-2 text-xl' />
</IconButton>
<Button
variant='contained'
size='small'
onClick={handleNewChat}
startIcon={<i className='tabler-plus' />}
sx={{
borderRadius: '12px',
minWidth: 'fit-content',
textTransform: 'none',
whiteSpace: 'nowrap'
}}
>
{t('sidebar.newChat')}
</Button>
</Box>
<Box
className='w-12 h-12 rounded-2xl flex items-center justify-center shrink-0'
sx={{
@@ -34,3 +34,10 @@ export interface FarmAIMessage {
// For structured AI responses
sections?: AIResponseSection[]
}
export interface ConversationSummary {
id: string
message_count: number
title?: string
updated_at?: string
}
@@ -1,372 +1,480 @@
'use client'
"use client";
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import Box from '@mui/material/Box'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import Typography from '@mui/material/Typography'
import Button from '@mui/material/Button'
import Collapse from '@mui/material/Collapse'
import CircularProgress from '@mui/material/CircularProgress'
import { useTheme, alpha } from '@mui/material/styles'
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import Collapse from "@mui/material/Collapse";
import CircularProgress from "@mui/material/CircularProgress";
import { useTheme, alpha } from "@mui/material/styles";
import type {
FarmData,
GrowthStage,
CropOption,
FertilizationPlan,
} from '@/libs/api/services/fertilizationRecommendationService'
import { fertilizationRecommendationService } from '@/libs/api/services/fertilizationRecommendationService'
} from "@/libs/api/services/fertilizationRecommendationService";
import { fertilizationRecommendationService } from "@/libs/api/services/fertilizationRecommendationService";
import { isRecommendationTaskRunning } from "@/libs/api/services/recommendationTask";
const DEFAULT_FARM_DATA: FarmData = {
soilType: 'Loamy',
organicMatter: 'Medium (2.5%)',
waterEC: '1.2 dS/m'
}
soilType: "Loamy",
organicMatter: "Medium (2.5%)",
waterEC: "1.2 dS/m",
};
const DEFAULT_GROWTH_STAGES: GrowthStage[] = [
{ id: 'prePlanting', icon: 'tabler-seedling' },
{ id: 'earlyGrowth', icon: 'tabler-leaf' },
{ id: 'flowering', icon: 'tabler-flower' },
{ id: 'fruiting', icon: 'tabler-apple' },
{ id: 'postHarvest', icon: 'tabler-basket' }
]
{ id: "prePlanting", icon: "tabler-seedling" },
{ id: "earlyGrowth", icon: "tabler-leaf" },
{ id: "flowering", icon: "tabler-flower" },
{ id: "fruiting", icon: "tabler-apple" },
{ id: "postHarvest", icon: "tabler-basket" },
];
const DEFAULT_CROP_OPTIONS: CropOption[] = [
{ id: 'wheat', labelKey: 'wheat', icon: 'tabler-wheat' },
{ id: 'corn', labelKey: 'corn', icon: 'tabler-plant-2' },
{ id: 'cotton', labelKey: 'cotton', icon: 'tabler-flower' },
{ id: 'saffron', labelKey: 'saffron', icon: 'tabler-flower-2' },
{ id: 'canola', labelKey: 'canola', icon: 'tabler-leaf' },
{ id: 'vegetables', labelKey: 'vegetables', icon: 'tabler-carrot' }
]
{ id: "wheat", labelKey: "wheat", icon: "tabler-wheat" },
{ id: "corn", labelKey: "corn", icon: "tabler-plant-2" },
{ id: "cotton", labelKey: "cotton", icon: "tabler-flower" },
{ id: "saffron", labelKey: "saffron", icon: "tabler-flower-2" },
{ id: "canola", labelKey: "canola", icon: "tabler-leaf" },
{ id: "vegetables", labelKey: "vegetables", icon: "tabler-carrot" },
];
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const getErrorMessage = (error: unknown, fallback: string) =>
typeof error === "object" &&
error !== null &&
"message" in error &&
typeof error.message === "string"
? error.message
: fallback;
export default function SmartFertilizationRecommendation() {
const t = useTranslations('fertilization')
const theme = useTheme()
const primaryMain = theme.palette.primary.main
const primaryLight = theme.palette.primary.light
const primaryDark = theme.palette.primary.dark
const paperBg = theme.palette.background.paper
const [farmData, setFarmData] = useState<FarmData>(DEFAULT_FARM_DATA)
const [growthStages, setGrowthStages] = useState<GrowthStage[]>(DEFAULT_GROWTH_STAGES)
const [cropOptions, setCropOptions] = useState<CropOption[]>(DEFAULT_CROP_OPTIONS)
const [configLoading, setConfigLoading] = useState(true)
const [configError, setConfigError] = useState<string | null>(null)
const [growthStage, setGrowthStage] = useState<string>(DEFAULT_GROWTH_STAGES[0].id)
const [selectedCrop, setSelectedCrop] = useState<string | null>(null)
const [plan, setPlan] = useState<FertilizationPlan | null>(null)
const [loading, setLoading] = useState(false)
const [reasoningExpanded, setReasoningExpanded] = useState(false)
const t = useTranslations("fertilization");
const theme = useTheme();
const primaryMain = theme.palette.primary.main;
const primaryLight = theme.palette.primary.light;
const primaryDark = theme.palette.primary.dark;
const paperBg = theme.palette.background.paper;
const [farmData, setFarmData] = useState<FarmData>(DEFAULT_FARM_DATA);
const [growthStages, setGrowthStages] = useState<GrowthStage[]>(
DEFAULT_GROWTH_STAGES,
);
const [cropOptions, setCropOptions] =
useState<CropOption[]>(DEFAULT_CROP_OPTIONS);
const [configLoading, setConfigLoading] = useState(true);
const [configError, setConfigError] = useState<string | null>(null);
const [growthStage, setGrowthStage] = useState<string>(
DEFAULT_GROWTH_STAGES[0].id,
);
const [selectedCrop, setSelectedCrop] = useState<string | null>(null);
const [plan, setPlan] = useState<FertilizationPlan | null>(null);
const [loading, setLoading] = useState(false);
const [requestError, setRequestError] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [reasoningExpanded, setReasoningExpanded] = useState(false);
useEffect(() => {
fertilizationRecommendationService
.getConfig()
.then(({ farmData: farm, growthStages: stages, cropOptions: crops }) => {
if (farm) setFarmData(farm)
if (farm) setFarmData(farm);
if (stages?.length) {
setGrowthStages(stages)
setGrowthStage(stages[0].id)
setGrowthStages(stages);
setGrowthStage(stages[0].id);
}
if (crops?.length) setCropOptions(crops)
if (crops?.length) setCropOptions(crops);
})
.catch((err: { message?: string }) => {
setConfigError(err?.message ?? 'Failed to load config')
setConfigError(err?.message ?? "Failed to load config");
})
.finally(() => setConfigLoading(false))
}, [])
.finally(() => setConfigLoading(false));
}, []);
const handleGenerate = async () => {
if (!selectedCrop) return
setLoading(true)
setPlan(null)
setReasoningExpanded(false)
if (!selectedCrop) return;
setLoading(true);
setPlan(null);
setRequestError(null);
setStatusMessage(t("generating"));
setReasoningExpanded(false);
try {
const { plan: nextPlan } = await fertilizationRecommendationService.recommend({
crop_id: selectedCrop,
growth_stage: growthStage,
soilType: farmData.soilType,
organicMatter: farmData.organicMatter,
waterEC: farmData.waterEC,
})
setPlan(nextPlan)
} catch {
setPlan(null)
} finally {
setLoading(false)
}
}
const recommendation = await fertilizationRecommendationService.recommend(
{
crop_id: selectedCrop,
growth_stage: growthStage,
farm_data: {
soilType: farmData.soilType,
organicMatter: farmData.organicMatter,
waterEC: farmData.waterEC,
},
soilType: farmData.soilType,
organicMatter: farmData.organicMatter,
waterEC: farmData.waterEC,
},
);
const stageIndex = growthStages.findIndex(s => s.id === growthStage)
if ("task_id" in recommendation) {
let attempts = 0;
let taskStatus =
await fertilizationRecommendationService.getRecommendStatus(
recommendation.task_id,
);
while (isRecommendationTaskRunning(taskStatus.status)) {
attempts += 1;
setStatusMessage(taskStatus.progress?.message ?? t("generating"));
if (attempts >= 20) {
throw new Error(t("errors.timeout"));
}
await sleep(1500);
taskStatus =
await fertilizationRecommendationService.getRecommendStatus(
recommendation.task_id,
);
}
if (taskStatus.status === "failed" || !taskStatus.result?.plan) {
throw new Error(taskStatus.error ?? t("errors.generateFailed"));
}
setPlan(taskStatus.result.plan);
return;
}
if (!recommendation.plan) {
throw new Error(t("errors.generateFailed"));
}
setPlan(recommendation.plan);
} catch (error) {
setPlan(null);
setRequestError(getErrorMessage(error, t("errors.generateFailed")));
} finally {
setLoading(false);
setStatusMessage(null);
}
};
const stageIndex = growthStages.findIndex((s) => s.id === growthStage);
return (
<Box
className='min-bs-screen'
className="min-bs-screen"
sx={{
background: (th) =>
`linear-gradient(165deg, ${alpha(th.palette.primary.main, 0.08)} 0%, ${alpha(th.palette.primary.main, 0.05)} 25%, ${alpha(th.palette.primary.main, 0.03)} 60%, ${th.palette.background.default} 100%)`,
minHeight: '100vh'
minHeight: "100vh",
}}
>
<Box className='max-w-lg mx-auto px-4 py-6 sm:py-8'>
<Box className="max-w-lg mx-auto px-4 py-6 sm:py-8">
{/* 1) Header */}
<Box className='mb-8'>
<Box className="mb-8">
<Typography
variant='h4'
className='font-bold tracking-tight'
variant="h4"
className="font-bold tracking-tight"
sx={{
background: `linear-gradient(135deg, ${primaryDark} 0%, ${primaryMain} 40%, ${primaryLight} 100%)`,
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
fontSize: { xs: '1.5rem', sm: '1.75rem' }
backgroundClip: "text",
WebkitBackgroundClip: "text",
color: "transparent",
fontSize: { xs: "1.5rem", sm: "1.75rem" },
}}
>
{t('title')}
{t("title")}
</Typography>
<Typography
variant='body2'
color='text.secondary'
className='mt-1 transition-colors duration-300'
variant="body2"
color="text.secondary"
className="mt-1 transition-colors duration-300"
>
{t('subtitle')}
{t("subtitle")}
</Typography>
</Box>
{/* 2) Farm Data Card */}
<Card
elevation={0}
className='mb-6 overflow-hidden transition-all duration-300 hover:shadow-lg animate-fade-in'
className="mb-6 overflow-hidden transition-all duration-300 hover:shadow-lg animate-fade-in"
sx={{
borderRadius: '28px',
borderRadius: "28px",
background: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 50%, ${alpha(primaryMain, 0.04)} 100%)`,
boxShadow: `0 4px 24px ${alpha(primaryMain, 0.08)}, 0 4px 12px ${alpha(primaryMain, 0.04)}, 0 1px 3px rgba(0,0,0,0.04)`,
border: `1px solid ${alpha(primaryMain, 0.12)}`
border: `1px solid ${alpha(primaryMain, 0.12)}`,
}}
>
<CardContent className='p-5'>
<Box className='flex items-center justify-between mbe-4'>
<Typography variant='subtitle2' fontWeight={600} color='text.secondary'>
{t('farmData.title')}
<CardContent className="p-5">
<Box className="flex items-center justify-between mbe-4">
<Typography
variant="subtitle2"
fontWeight={600}
color="text.secondary"
>
{t("farmData.title")}
</Typography>
<Box
className='px-2.5 py-1 rounded-full text-xs font-medium flex items-center gap-1.5'
className="px-2.5 py-1 rounded-full text-xs font-medium flex items-center gap-1.5"
sx={{
background: (th) => `linear-gradient(135deg, ${th.palette.success.main} 0%, ${th.palette.success.dark} 100%)`,
color: 'white',
boxShadow: (th) => `0 2px 8px ${alpha(th.palette.success.main, 0.3)}`
background: (th) =>
`linear-gradient(135deg, ${th.palette.success.main} 0%, ${th.palette.success.dark} 100%)`,
color: "white",
boxShadow: (th) =>
`0 2px 8px ${alpha(th.palette.success.main, 0.3)}`,
}}
>
<i className='tabler-circle-check text-sm' />
{t('verifiedBadge')}
<i className="tabler-circle-check text-sm" />
{t("verifiedBadge")}
</Box>
</Box>
<Box className='flex flex-wrap gap-3'>
<FarmBadge icon='tabler-seedling' label={t('farmData.soilType')} value={farmData.soilType} />
<Box className="flex flex-wrap gap-3">
<FarmBadge
icon='tabler-atom-2'
label={t('farmData.organicMatter')}
icon="tabler-seedling"
label={t("farmData.soilType")}
value={farmData.soilType}
/>
<FarmBadge
icon="tabler-atom-2"
label={t("farmData.organicMatter")}
value={farmData.organicMatter}
/>
<FarmBadge icon='tabler-droplet' label={t('farmData.waterEC')} value={farmData.waterEC} />
<FarmBadge
icon="tabler-droplet"
label={t("farmData.waterEC")}
value={farmData.waterEC}
/>
</Box>
</CardContent>
</Card>
{/* 3) Growth Stage Selector */}
<Typography variant='subtitle2' fontWeight={600} color='text.secondary' className='mbe-3'>
{t('growthStage.title')}
<Typography
variant="subtitle2"
fontWeight={600}
color="text.secondary"
className="mbe-3"
>
{t("growthStage.title")}
</Typography>
<Box className='flex gap-2 mb-6 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide'>
<Box className="flex gap-2 mb-6 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide">
{growthStages.map((stage, idx) => {
const isSelected = growthStage === stage.id
const isPast = idx < stageIndex
const isSelected = growthStage === stage.id;
const isPast = idx < stageIndex;
return (
<Box
key={stage.id}
component='button'
type='button'
component="button"
type="button"
onClick={() => setGrowthStage(stage.id)}
className='flex flex-col items-center gap-1.5 px-4 py-3 rounded-2xl shrink-0 transition-all duration-300 ease-out border-2 cursor-pointer min-w-[72px]'
className="flex flex-col items-center gap-1.5 px-4 py-3 rounded-2xl shrink-0 transition-all duration-300 ease-out border-2 cursor-pointer min-w-[72px]"
sx={{
borderColor: isSelected ? primaryMain : 'transparent',
borderColor: isSelected ? primaryMain : "transparent",
background: isSelected
? `linear-gradient(145deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.06)} 100%)`
: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
boxShadow: isSelected
? `0 4px 20px ${alpha(primaryMain, 0.2)}, inset 0 1px 0 rgba(255,255,255,0.8)`
: '0 2px 8px rgba(0,0,0,0.04)',
'&:hover': {
transform: 'translateY(-2px)',
: "0 2px 8px rgba(0,0,0,0.04)",
"&:hover": {
transform: "translateY(-2px)",
boxShadow: isSelected
? `0 6px 24px ${alpha(primaryMain, 0.25)}`
: `0 4px 16px ${alpha(primaryMain, 0.1)}`
}
: `0 4px 16px ${alpha(primaryMain, 0.1)}`,
},
}}
>
<Box
className='w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300'
className="w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300"
sx={{
background: isSelected
? `linear-gradient(135deg, ${primaryMain} 0%, ${primaryDark} 100%)`
: isPast
? `linear-gradient(145deg, ${alpha(primaryMain, 0.2)} 0%, ${alpha(primaryMain, 0.1)} 100%)`
: alpha(primaryMain, 0.08)
: alpha(primaryMain, 0.08),
}}
>
<i
className={`${stage.icon} text-xl transition-colors duration-300 ${isSelected ? 'text-white' : ''}`}
className={`${stage.icon} text-xl transition-colors duration-300 ${isSelected ? "text-white" : ""}`}
style={!isSelected ? { color: primaryMain } : undefined}
/>
</Box>
<Typography
variant='caption'
variant="caption"
fontWeight={600}
sx={{
color: isSelected ? 'primary.main' : 'text.secondary',
textAlign: 'center',
lineHeight: 1.2
color: isSelected ? "primary.main" : "text.secondary",
textAlign: "center",
lineHeight: 1.2,
}}
>
{t(`growthStage.${stage.id}`)}
</Typography>
</Box>
)
);
})}
</Box>
{/* 4) Plant Selection */}
<Typography variant='subtitle2' fontWeight={600} color='text.secondary' className='mbe-3'>
{t('plantSelection.title')}
<Typography
variant="subtitle2"
fontWeight={600}
color="text.secondary"
className="mbe-3"
>
{t("plantSelection.title")}
</Typography>
{configLoading ? (
<Box className='flex justify-center py-8 mb-6'>
<CircularProgress size={32} sx={{ color: 'primary.main' }} />
<Box className="flex justify-center py-8 mb-6">
<CircularProgress size={32} sx={{ color: "primary.main" }} />
</Box>
) : configError ? (
<Typography variant='body2' color='error' className='mb-6'>
<Typography variant="body2" color="error" className="mb-6">
{configError}
</Typography>
) : (
<Box className='flex flex-wrap gap-3 mb-6'>
{cropOptions.map(crop => (
<CropCard
key={crop.id}
crop={crop}
label={t(`crops.${crop.labelKey}`)}
selected={selectedCrop === crop.id}
onClick={() =>
setSelectedCrop(prev => (prev === crop.id ? null : crop.id))
}
/>
))}
</Box>
<Box className="flex flex-wrap gap-3 mb-6">
{cropOptions.map((crop) => (
<CropCard
key={crop.id}
crop={crop}
label={t(`crops.${crop.labelKey}`)}
selected={selectedCrop === crop.id}
onClick={() =>
setSelectedCrop((prev) => (prev === crop.id ? null : crop.id))
}
/>
))}
</Box>
)}
{/* 5) Primary CTA Button - End of form */}
<Box className='mb-8'>
<Box className="mb-8">
<Button
fullWidth
variant='contained'
variant="contained"
disabled={!selectedCrop || loading || configLoading}
onClick={handleGenerate}
startIcon={<i className='tabler-sparkles text-xl' />}
className='rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]'
startIcon={<i className="tabler-sparkles text-xl" />}
className="rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]"
sx={{
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
boxShadow: `0 4px 20px ${alpha(primaryMain, 0.4)}`,
'&:hover': {
"&:hover": {
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
boxShadow: `0 6px 28px ${alpha(primaryMain, 0.5)}`,
filter: 'brightness(1.05)'
filter: "brightness(1.05)",
},
"&:disabled": {
background: "action.disabledBackground",
color: "action.disabled",
},
'&:disabled': {
background: 'action.disabledBackground',
color: 'action.disabled'
}
}}
>
{t('generateCta')}
{t("generateCta")}
</Button>
</Box>
{requestError && !loading && (
<Typography variant="body2" color="error" className="mb-6">
{requestError}
</Typography>
)}
{/* 6) Result Section - Prescription style */}
{plan && (
<Box className='mb-6 animate-fade-in'>
<Box className="mb-6 animate-fade-in">
<Card
elevation={0}
sx={{
borderRadius: '28px',
borderRadius: "28px",
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 40%, ${alpha(primaryMain, 0.04)} 100%)`,
boxShadow: `0 8px 32px ${alpha(primaryMain, 0.12)}, 0 4px 16px ${alpha(primaryMain, 0.06)}, 0 2px 8px rgba(0,0,0,0.04)`,
border: `1px solid ${alpha(primaryMain, 0.15)}`,
overflow: 'visible'
overflow: "visible",
}}
>
<CardContent className='p-6'>
<Box className='flex items-center gap-2 mbe-5'>
<i className='tabler-prescription text-2xl' style={{ color: primaryMain }} />
<Typography variant='h6' fontWeight={700} color='text.primary'>
{t('result.title')}
<CardContent className="p-6">
<Box className="flex items-center gap-2 mbe-5">
<i
className="tabler-prescription text-2xl"
style={{ color: primaryMain }}
/>
<Typography
variant="h6"
fontWeight={700}
color="text.primary"
>
{t("result.title")}
</Typography>
</Box>
<Box className='space-y-3'>
<Box className="space-y-3">
<PrescriptionRow
icon='tabler-atom-2'
label={t('result.fertilizerType')}
icon="tabler-atom-2"
label={t("result.fertilizerType")}
value={plan.npkRatio}
/>
<PrescriptionRow
icon='tabler-scale'
label={t('result.amountPerHectare')}
icon="tabler-scale"
label={t("result.amountPerHectare")}
value={plan.amountPerHectare}
/>
<PrescriptionRow
icon='tabler-spray'
label={t('result.applicationMethod')}
icon="tabler-spray"
label={t("result.applicationMethod")}
value={plan.applicationMethod}
/>
<PrescriptionRow
icon='tabler-calendar-repeat'
label={t('result.applicationInterval')}
icon="tabler-calendar-repeat"
label={t("result.applicationInterval")}
value={plan.applicationInterval}
/>
</Box>
{/* Expandable "Why this recommendation?" */}
<Box
className='mt-5 rounded-2xl overflow-hidden transition-all duration-300'
className="mt-5 rounded-2xl overflow-hidden transition-all duration-300"
sx={{
border: `1px solid ${alpha(primaryMain, 0.15)}`,
background: alpha(primaryMain, 0.04)
background: alpha(primaryMain, 0.04),
}}
>
<Box
component='button'
type='button'
component="button"
type="button"
onClick={() => setReasoningExpanded(!reasoningExpanded)}
className='w-full flex items-center justify-between px-4 py-3 text-start cursor-pointer'
sx={{ '&:hover': { bgcolor: alpha(primaryMain, 0.06) } }}
className="w-full flex items-center justify-between px-4 py-3 text-start cursor-pointer"
sx={{ "&:hover": { bgcolor: alpha(primaryMain, 0.06) } }}
>
<Box className='flex items-center gap-2'>
<i className='tabler-brain text-lg' style={{ color: primaryMain }} />
<Typography variant='subtitle2' fontWeight={600} color='text.primary'>
{t('result.whyRecommendation')}
<Box className="flex items-center gap-2">
<i
className="tabler-brain text-lg"
style={{ color: primaryMain }}
/>
<Typography
variant="subtitle2"
fontWeight={600}
color="text.primary"
>
{t("result.whyRecommendation")}
</Typography>
</Box>
<i
className={`tabler-chevron-down text-xl transition-transform duration-300 ${
reasoningExpanded ? 'rotate-180' : ''
reasoningExpanded ? "rotate-180" : ""
}`}
style={{ color: primaryMain }}
/>
</Box>
<Collapse in={reasoningExpanded}>
<Box className='px-4 pb-4'>
<Box className="px-4 pb-4">
<Typography
variant='body2'
color='text.secondary'
variant="body2"
color="text.secondary"
sx={{ lineHeight: 1.7 }}
>
{plan.reasoning}
@@ -383,32 +491,35 @@ export default function SmartFertilizationRecommendation() {
{loading && (
<Card
elevation={0}
className='mb-6 animate-fade-in'
className="mb-6 animate-fade-in"
sx={{
borderRadius: '28px',
borderRadius: "28px",
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 100%)`,
boxShadow: `0 8px 32px ${alpha(primaryMain, 0.1)}`,
border: `1px solid ${alpha(primaryMain, 0.12)}`
border: `1px solid ${alpha(primaryMain, 0.12)}`,
}}
>
<CardContent className='p-12 flex flex-col items-center gap-4'>
<CardContent className="p-12 flex flex-col items-center gap-4">
<Box
className='w-14 h-14 rounded-2xl flex items-center justify-center animate-pulse'
className="w-14 h-14 rounded-2xl flex items-center justify-center animate-pulse"
sx={{
background: `linear-gradient(135deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.08)} 100%)`
background: `linear-gradient(135deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.08)} 100%)`,
}}
>
<i className='tabler-sparkles text-2xl' style={{ color: primaryMain }} />
<i
className="tabler-sparkles text-2xl"
style={{ color: primaryMain }}
/>
</Box>
<Typography variant='body2' color='text.secondary'>
{t('generating')}
<Typography variant="body2" color="text.secondary">
{statusMessage ?? t("generating")}
</Typography>
</CardContent>
</Card>
)}
</Box>
</Box>
)
);
}
// ─── Sub-components ──────────────────────────────────────────────────────────
@@ -416,129 +527,140 @@ export default function SmartFertilizationRecommendation() {
function FarmBadge({
icon,
label,
value
value,
}: {
icon: string
label: string
value: string
icon: string;
label: string;
value: string;
}) {
const theme = useTheme()
const primaryMain = theme.palette.primary.main
const theme = useTheme();
const primaryMain = theme.palette.primary.main;
return (
<Box
className='flex items-center gap-2 px-4 py-2.5 rounded-2xl transition-all duration-200 hover:scale-[1.02] hover:shadow-md'
className="flex items-center gap-2 px-4 py-2.5 rounded-2xl transition-all duration-200 hover:scale-[1.02] hover:shadow-md"
sx={{
background: `linear-gradient(145deg, ${alpha(primaryMain, 0.1)} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
border: `1px solid ${alpha(primaryMain, 0.15)}`,
boxShadow: 'inset 0 1px 2px rgba(255,255,255,0.5)'
boxShadow: "inset 0 1px 2px rgba(255,255,255,0.5)",
}}
>
<i className={`${icon} text-xl`} style={{ color: primaryMain }} />
<Box>
<Typography variant='caption' color='text.secondary' display='block' lineHeight={1.2}>
<Typography
variant="caption"
color="text.secondary"
display="block"
lineHeight={1.2}
>
{label}
</Typography>
<Typography variant='body2' fontWeight={600} color='text.primary'>
<Typography variant="body2" fontWeight={600} color="text.primary">
{value}
</Typography>
</Box>
</Box>
)
);
}
function CropCard({
crop,
label,
selected,
onClick
onClick,
}: {
crop: CropOption
label: string
selected: boolean
onClick: () => void
crop: CropOption;
label: string;
selected: boolean;
onClick: () => void;
}) {
const theme = useTheme()
const primaryMain = theme.palette.primary.main
const primaryDark = theme.palette.primary.dark
const paperBg = theme.palette.background.paper
const theme = useTheme();
const primaryMain = theme.palette.primary.main;
const primaryDark = theme.palette.primary.dark;
const paperBg = theme.palette.background.paper;
return (
<Card
component='button'
type='button'
component="button"
type="button"
elevation={0}
onClick={onClick}
className='flex items-center gap-3 px-4 py-3 rounded-2xl cursor-pointer transition-all duration-300 border-2 text-start'
className="flex items-center gap-3 px-4 py-3 rounded-2xl cursor-pointer transition-all duration-300 border-2 text-start"
sx={{
borderColor: selected ? primaryMain : 'transparent',
borderColor: selected ? primaryMain : "transparent",
background: selected
? `linear-gradient(145deg, ${alpha(primaryMain, 0.15)} 0%, ${alpha(primaryMain, 0.06)} 100%)`
: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
boxShadow: selected
? `0 4px 20px ${alpha(primaryMain, 0.2)}, inset 0 1px 0 rgba(255,255,255,0.8)`
: '0 2px 8px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.9)',
'&:hover': {
transform: 'translateY(-2px)',
: "0 2px 8px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.9)",
"&:hover": {
transform: "translateY(-2px)",
boxShadow: selected
? `0 6px 24px ${alpha(primaryMain, 0.25)}`
: `0 4px 16px ${alpha(primaryMain, 0.12)}`
}
: `0 4px 16px ${alpha(primaryMain, 0.12)}`,
},
}}
>
<Box
className='w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-all duration-300'
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-all duration-300"
sx={{
background: selected
? `linear-gradient(135deg, ${primaryMain} 0%, ${primaryDark} 100%)`
: `linear-gradient(145deg, ${alpha(primaryMain, 0.1)} 0%, ${alpha(primaryMain, 0.05)} 100%)`
: `linear-gradient(145deg, ${alpha(primaryMain, 0.1)} 0%, ${alpha(primaryMain, 0.05)} 100%)`,
}}
>
<i
className={`${crop.icon} text-xl ${selected ? 'text-white' : ''}`}
className={`${crop.icon} text-xl ${selected ? "text-white" : ""}`}
style={!selected ? { color: primaryMain } : undefined}
/>
</Box>
<Typography
variant='body2'
variant="body2"
fontWeight={600}
color={selected ? 'primary.main' : 'text.primary'}
color={selected ? "primary.main" : "text.primary"}
>
{label}
</Typography>
{selected && (
<i className='tabler-circle-check-filled text-xl ms-auto' style={{ color: primaryMain }} />
<i
className="tabler-circle-check-filled text-xl ms-auto"
style={{ color: primaryMain }}
/>
)}
</Card>
)
);
}
function PrescriptionRow({
icon,
label,
value
value,
}: {
icon: string
label: string
value: string
icon: string;
label: string;
value: string;
}) {
const theme = useTheme()
const primaryMain = theme.palette.primary.main
const theme = useTheme();
const primaryMain = theme.palette.primary.main;
return (
<Box
className='flex items-center gap-4 p-3 rounded-2xl transition-colors duration-200'
className="flex items-center gap-4 p-3 rounded-2xl transition-colors duration-200"
sx={{
background: alpha(primaryMain, 0.06),
border: `1px solid ${alpha(primaryMain, 0.08)}`
border: `1px solid ${alpha(primaryMain, 0.08)}`,
}}
>
<i className={`${icon} text-2xl shrink-0`} style={{ color: primaryMain }} />
<Box className='flex-1 min-w-0'>
<Typography variant='caption' color='text.secondary'>
<i
className={`${icon} text-2xl shrink-0`}
style={{ color: primaryMain }}
/>
<Box className="flex-1 min-w-0">
<Typography variant="caption" color="text.secondary">
{label}
</Typography>
<Typography variant='body1' fontWeight={600} color='text.primary'>
<Typography variant="body1" fontWeight={600} color="text.primary">
{value}
</Typography>
</Box>
</Box>
)
);
}
@@ -1,286 +1,480 @@
'use client'
"use client";
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import Box from '@mui/material/Box'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import Typography from '@mui/material/Typography'
import Button from '@mui/material/Button'
import IconButton from '@mui/material/IconButton'
import CircularProgress from '@mui/material/CircularProgress'
import { useTheme, alpha } from '@mui/material/styles'
import type { FarmInfo, CropOption, IrrigationPlan } from '@/libs/api/services/irrigationRecommendationService'
import { irrigationRecommendationService } from '@/libs/api/services/irrigationRecommendationService'
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress";
import { useTheme, alpha } from "@mui/material/styles";
import type {
FarmInfo,
CropOption,
IrrigationPlan,
WaterBalance,
} from "@/libs/api/services/irrigationRecommendationService";
import { irrigationRecommendationService } from "@/libs/api/services/irrigationRecommendationService";
import { isRecommendationTaskRunning } from "@/libs/api/services/recommendationTask";
const DEFAULT_FARM_INFO: FarmInfo = {
soilType: 'Loamy',
waterQuality: 'Medium EC',
climateZone: 'Temperate'
}
soilType: "Loamy",
waterQuality: "Medium EC",
climateZone: "Temperate",
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const getErrorMessage = (error: unknown, fallback: string) =>
typeof error === "object" &&
error !== null &&
"message" in error &&
typeof error.message === "string"
? error.message
: fallback;
export default function SmartIrrigationRecommendation() {
const t = useTranslations('irrigation')
const theme = useTheme()
const [farmInfo, setFarmInfo] = useState<FarmInfo>(DEFAULT_FARM_INFO)
const [cropOptions, setCropOptions] = useState<CropOption[]>([])
const [configLoading, setConfigLoading] = useState(true)
const [configError, setConfigError] = useState<string | null>(null)
const [selectedCrop, setSelectedCrop] = useState<string | null>(null)
const [plan, setPlan] = useState<IrrigationPlan | null>(null)
const [loading, setLoading] = useState(false)
const primaryMain = theme.palette.primary.main
const primaryLight = theme.palette.primary.light
const primaryDark = theme.palette.primary.dark
const paperBg = theme.palette.background.paper
const t = useTranslations("irrigation");
const theme = useTheme();
const [farmInfo, setFarmInfo] = useState<FarmInfo>(DEFAULT_FARM_INFO);
const [cropOptions, setCropOptions] = useState<CropOption[]>([]);
const [configLoading, setConfigLoading] = useState(true);
const [configError, setConfigError] = useState<string | null>(null);
const [selectedCrop, setSelectedCrop] = useState<string | null>(null);
const [plan, setPlan] = useState<IrrigationPlan | null>(null);
const [waterBalance, setWaterBalance] = useState<WaterBalance | null>(null);
const [loading, setLoading] = useState(false);
const [requestError, setRequestError] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const primaryMain = theme.palette.primary.main;
const primaryLight = theme.palette.primary.light;
const primaryDark = theme.palette.primary.dark;
const paperBg = theme.palette.background.paper;
useEffect(() => {
irrigationRecommendationService
.getConfig()
.then(({ farmInfo: info, cropOptions: crops }) => {
setFarmInfo(info)
setCropOptions(crops.length > 0 ? crops : [])
setFarmInfo(info);
setCropOptions(crops.length > 0 ? crops : []);
})
.catch((err: { message?: string }) => {
setConfigError(err?.message ?? 'Failed to load config')
setConfigError(err?.message ?? "Failed to load config");
})
.finally(() => setConfigLoading(false))
}, [])
.finally(() => setConfigLoading(false));
}, []);
const handleGenerate = async () => {
if (!selectedCrop) return
setLoading(true)
setPlan(null)
if (!selectedCrop) return;
setLoading(true);
setPlan(null);
setWaterBalance(null);
setRequestError(null);
setStatusMessage(t("generating"));
try {
const { plan: nextPlan } = await irrigationRecommendationService.recommend({
const recommendation = await irrigationRecommendationService.recommend({
crop_id: selectedCrop,
})
setPlan(nextPlan)
} catch {
setPlan(null)
farm_data: {
soilType: farmInfo.soilType,
waterQuality: farmInfo.waterQuality,
climateZone: farmInfo.climateZone,
},
soilType: farmInfo.soilType,
waterQuality: farmInfo.waterQuality,
climateZone: farmInfo.climateZone,
});
if ("task_id" in recommendation) {
let attempts = 0;
let taskStatus =
await irrigationRecommendationService.getRecommendStatus(
recommendation.task_id,
);
while (isRecommendationTaskRunning(taskStatus.status)) {
attempts += 1;
setStatusMessage(taskStatus.progress?.message ?? t("generating"));
if (attempts >= 20) {
throw new Error(t("errors.timeout"));
}
await sleep(1500);
taskStatus = await irrigationRecommendationService.getRecommendStatus(
recommendation.task_id,
);
}
if (taskStatus.status === "failed" || !taskStatus.result?.plan) {
throw new Error(taskStatus.error ?? t("errors.generateFailed"));
}
setPlan(taskStatus.result.plan);
setWaterBalance(taskStatus.result.water_balance ?? null);
return;
}
if (!recommendation.plan) {
throw new Error(t("errors.generateFailed"));
}
setPlan(recommendation.plan);
setWaterBalance(recommendation.water_balance ?? null);
} catch (error) {
setPlan(null);
setWaterBalance(null);
setRequestError(getErrorMessage(error, t("errors.generateFailed")));
} finally {
setLoading(false)
setLoading(false);
setStatusMessage(null);
}
}
};
const moistureLevelValue =
typeof plan?.moistureLevel === "number"
? plan.moistureLevel
: Number(plan?.moistureLevel);
const hasNumericMoistureLevel = Number.isFinite(moistureLevelValue);
const nextWaterBalanceDay = waterBalance?.daily?.[0];
return (
<Box
className='min-bs-screen'
className="min-bs-screen"
sx={{
background: (theme) =>
`linear-gradient(165deg, ${alpha(theme.palette.primary.main, 0.08)} 0%, ${alpha(theme.palette.primary.main, 0.04)} 35%, ${alpha(theme.palette.primary.main, 0.02)} 70%, ${theme.palette.background.default} 100%)`,
minHeight: '100vh'
minHeight: "100vh",
}}
>
<Box className='max-w-lg mx-auto px-4 py-6 sm:py-8'>
<Box className="max-w-lg mx-auto px-4 py-6 sm:py-8">
{/* 1) Dynamic Header */}
<Box className='mb-8'>
<Box className="mb-8">
<Typography
variant='h4'
className='font-bold tracking-tight'
variant="h4"
className="font-bold tracking-tight"
sx={{
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
fontSize: { xs: '1.5rem', sm: '1.75rem' }
backgroundClip: "text",
WebkitBackgroundClip: "text",
color: "transparent",
fontSize: { xs: "1.5rem", sm: "1.75rem" },
}}
>
{t('title')}
{t("title")}
</Typography>
<Typography variant='body2' color='text.secondary' className='mt-1'>
{t('subtitle')}
<Typography variant="body2" color="text.secondary" className="mt-1">
{t("subtitle")}
</Typography>
</Box>
{/* 2) Farm Info Card */}
<Card
elevation={0}
className='mb-6 overflow-hidden transition-all duration-300 hover:shadow-lg'
className="mb-6 overflow-hidden transition-all duration-300 hover:shadow-lg"
sx={{
borderRadius: '24px',
borderRadius: "24px",
background: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
boxShadow: `0 4px 24px ${alpha(primaryMain, 0.08)}, 0 1px 3px rgba(0,0,0,0.04)`,
border: `1px solid ${alpha(primaryMain, 0.12)}`
border: `1px solid ${alpha(primaryMain, 0.12)}`,
}}
>
<CardContent className='p-5'>
<Box className='flex items-center justify-between mbe-4'>
<Typography variant='subtitle2' fontWeight={600} color='text.secondary'>
{t('farmInfo.title')}
<CardContent className="p-5">
<Box className="flex items-center justify-between mbe-4">
<Typography
variant="subtitle2"
fontWeight={600}
color="text.secondary"
>
{t("farmInfo.title")}
</Typography>
<Box className='flex items-center gap-2'>
<Box className="flex items-center gap-2">
<Box
className='px-2.5 py-1 rounded-full text-xs font-medium'
className="px-2.5 py-1 rounded-full text-xs font-medium"
sx={{
background: (t) => `linear-gradient(135deg, ${t.palette.success.main} 0%, ${t.palette.success.dark} 100%)`,
color: 'white',
display: 'flex',
alignItems: 'center',
gap: 4
background: (t) =>
`linear-gradient(135deg, ${t.palette.success.main} 0%, ${t.palette.success.dark} 100%)`,
color: "white",
display: "flex",
alignItems: "center",
gap: 4,
}}
>
<i className='tabler-circle-check text-sm' />
{t('verifiedBadge')}
<i className="tabler-circle-check text-sm" />
{t("verifiedBadge")}
</Box>
<IconButton size='small' sx={{ color: 'text.secondary' }} aria-label={t('editFarmInfo')}>
<i className='tabler-pencil text-lg' />
<IconButton
size="small"
sx={{ color: "text.secondary" }}
aria-label={t("editFarmInfo")}
>
<i className="tabler-pencil text-lg" />
</IconButton>
</Box>
</Box>
<Box className='flex flex-wrap gap-3'>
<FarmBadge icon='tabler-seedling' label={t('farmInfo.soilType')} value={farmInfo.soilType} />
<FarmBadge icon='tabler-droplet' label={t('farmInfo.waterQuality')} value={farmInfo.waterQuality} />
<FarmBadge icon='tabler-temperature' label={t('farmInfo.climateZone')} value={farmInfo.climateZone} />
<Box className="flex flex-wrap gap-3">
<FarmBadge
icon="tabler-seedling"
label={t("farmInfo.soilType")}
value={farmInfo.soilType}
/>
<FarmBadge
icon="tabler-droplet"
label={t("farmInfo.waterQuality")}
value={farmInfo.waterQuality}
/>
<FarmBadge
icon="tabler-temperature"
label={t("farmInfo.climateZone")}
value={farmInfo.climateZone}
/>
</Box>
</CardContent>
</Card>
{/* 3) Plant Selection Section */}
<Typography variant='subtitle2' fontWeight={600} color='text.secondary' className='mbe-3'>
{t('plantSelection.title')}
<Typography
variant="subtitle2"
fontWeight={600}
color="text.secondary"
className="mbe-3"
>
{t("plantSelection.title")}
</Typography>
{configLoading ? (
<Box className='flex justify-center py-8'>
<CircularProgress size={32} sx={{ color: 'primary.main' }} />
<Box className="flex justify-center py-8">
<CircularProgress size={32} sx={{ color: "primary.main" }} />
</Box>
) : configError ? (
<Typography variant='body2' color='error' className='mb-6'>
<Typography variant="body2" color="error" className="mb-6">
{configError}
</Typography>
) : (
<Box className='flex flex-wrap gap-3 mb-6'>
{(cropOptions.length > 0 ? cropOptions : []).map(crop => (
<CropCard
key={crop.id}
crop={crop}
label={t(`crops.${crop.labelKey}`)}
selected={selectedCrop === crop.id}
onClick={() => setSelectedCrop(prev => (prev === crop.id ? null : crop.id))}
/>
))}
</Box>
<Box className="flex flex-wrap gap-3 mb-6">
{(cropOptions.length > 0 ? cropOptions : []).map((crop) => (
<CropCard
key={crop.id}
crop={crop}
label={t(`crops.${crop.labelKey}`)}
selected={selectedCrop === crop.id}
onClick={() =>
setSelectedCrop((prev) => (prev === crop.id ? null : crop.id))
}
/>
))}
</Box>
)}
{/* 4) Primary CTA Button - End of form */}
<Box className='mb-8'>
<Box className="mb-8">
<Button
fullWidth
variant='contained'
variant="contained"
disabled={!selectedCrop || loading || configLoading}
onClick={handleGenerate}
startIcon={<i className='tabler-sparkles text-xl' />}
className='rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]'
startIcon={<i className="tabler-sparkles text-xl" />}
className="rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]"
sx={{
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
boxShadow: `0 4px 20px ${alpha(primaryMain, 0.4)}`,
'&:hover': {
"&:hover": {
background: `linear-gradient(135deg, ${primaryLight} 0%, ${primaryMain} 50%, ${primaryDark} 100%)`,
boxShadow: `0 6px 28px ${alpha(primaryMain, 0.5)}`,
filter: 'brightness(1.05)'
filter: "brightness(1.05)",
},
"&:disabled": {
background: "action.disabledBackground",
color: "action.disabled",
},
'&:disabled': {
background: 'action.disabledBackground',
color: 'action.disabled'
}
}}
>
{t('generateCta')}
{t("generateCta")}
</Button>
</Box>
{requestError && !loading && (
<Typography variant="body2" color="error" className="mb-6">
{requestError}
</Typography>
)}
{/* 5) Result Card (after click) */}
{plan && (
<Box className='mb-6 animate-fade-in'>
<Box className="mb-6 animate-fade-in">
<Card
elevation={0}
sx={{
borderRadius: '24px',
borderRadius: "24px",
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 100%)`,
boxShadow: `0 8px 32px ${alpha(primaryMain, 0.15)}, 0 2px 8px rgba(0,0,0,0.06)`,
border: `1px solid ${alpha(primaryMain, 0.18)}`,
overflow: 'visible'
overflow: "visible",
}}
>
<CardContent className='p-6'>
<CardContent className="p-6">
{/* Circular moisture indicator */}
<Box className='flex justify-center mbe-6'>
<Box className='relative'>
<svg width={120} height={120} className='-rotate-90'>
<circle
cx={60}
cy={60}
r={52}
fill='none'
stroke={alpha(primaryMain, 0.12)}
strokeWidth={10}
/>
<circle
cx={60}
cy={60}
r={52}
fill='none'
stroke='url(#moistureGradient)'
strokeWidth={10}
strokeLinecap='round'
strokeDasharray={`${(plan.moistureLevel / 100) * 327} 327`}
className='transition-all duration-1000 ease-out'
/>
<defs>
<linearGradient id='moistureGradient' x1='0%' y1='0%' x2='100%' y2='100%'>
<stop offset='0%' stopColor={primaryLight} />
<stop offset='100%' stopColor={primaryMain} />
</linearGradient>
</defs>
</svg>
<Box className="flex justify-center mbe-6">
{hasNumericMoistureLevel ? (
<Box className="relative">
<svg width={120} height={120} className="-rotate-90">
<circle
cx={60}
cy={60}
r={52}
fill="none"
stroke={alpha(primaryMain, 0.12)}
strokeWidth={10}
/>
<circle
cx={60}
cy={60}
r={52}
fill="none"
stroke="url(#moistureGradient)"
strokeWidth={10}
strokeLinecap="round"
strokeDasharray={`${(moistureLevelValue / 100) * 327} 327`}
className="transition-all duration-1000 ease-out"
/>
<defs>
<linearGradient
id="moistureGradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop offset="0%" stopColor={primaryLight} />
<stop offset="100%" stopColor={primaryMain} />
</linearGradient>
</defs>
</svg>
<Box
className="absolute inset-0 flex flex-col items-center justify-center"
sx={{ top: 0, left: 0, right: 0, bottom: 0 }}
>
<i
className="tabler-droplet text-3xl mbe-0.5"
style={{ color: primaryMain }}
/>
<Typography
variant="h4"
fontWeight={700}
color="primary.main"
>
{moistureLevelValue}%
</Typography>
<Typography variant="caption" color="text.secondary">
{t("result.moistureLevel")}
</Typography>
</Box>
</Box>
) : (
<Box
className='absolute inset-0 flex flex-col items-center justify-center'
sx={{ top: 0, left: 0, right: 0, bottom: 0 }}
className="flex flex-col items-center justify-center px-8 py-6 rounded-[28px] text-center"
sx={{
minWidth: 180,
background: `linear-gradient(145deg, ${alpha(primaryMain, 0.12)} 0%, ${alpha(primaryMain, 0.05)} 100%)`,
border: `1px solid ${alpha(primaryMain, 0.15)}`,
}}
>
<i className='tabler-droplet text-3xl mbe-0.5' style={{ color: primaryMain }} />
<Typography variant='h4' fontWeight={700} color='primary.main'>
{plan.moistureLevel}%
<i
className="tabler-droplet text-4xl mbe-2"
style={{ color: primaryMain }}
/>
<Typography
variant="h5"
fontWeight={700}
color="primary.main"
className="mbe-1"
>
{String(plan.moistureLevel)}
</Typography>
<Typography variant='caption' color='text.secondary'>
{t('result.moistureLevel')}
<Typography variant="caption" color="text.secondary">
{t("result.moistureLevel")}
</Typography>
</Box>
</Box>
)}
</Box>
<Box className='space-y-4'>
<Box className="space-y-4">
<ResultRow
icon='tabler-calendar-week'
label={t('result.frequency')}
value={`${plan.frequencyPerWeek} ${t('result.timesPerWeek')}`}
icon="tabler-calendar-week"
label={t("result.frequency")}
value={`${plan.frequencyPerWeek} ${t("result.timesPerWeek")}`}
/>
<ResultRow
icon='tabler-clock'
label={t('result.duration')}
value={`${plan.durationMinutes} ${t('result.minutes')}`}
icon="tabler-clock"
label={t("result.duration")}
value={`${plan.durationMinutes} ${t("result.minutes")}`}
/>
<ResultRow
icon='tabler-sunrise'
label={t('result.bestTime')}
icon="tabler-sunrise"
label={t("result.bestTime")}
value={plan.bestTimeOfDay}
/>
</Box>
{waterBalance && (
<Box className="mt-5 space-y-4">
<Typography
variant="subtitle2"
fontWeight={600}
color="text.secondary"
>
{t("result.waterBalance")}
</Typography>
{nextWaterBalanceDay && (
<>
<ResultRow
icon="tabler-calendar-event"
label={t("result.forecastDate")}
value={nextWaterBalanceDay.forecast_date}
/>
<ResultRow
icon="tabler-droplet"
label={t("result.grossIrrigation")}
value={`${nextWaterBalanceDay.gross_irrigation_mm} mm`}
/>
<ResultRow
icon="tabler-clock-hour-6"
label={t("result.irrigationTiming")}
value={nextWaterBalanceDay.irrigation_timing}
/>
</>
)}
{typeof waterBalance.active_kc === "number" && (
<ResultRow
icon="tabler-chart-line"
label={t("result.activeKc")}
value={String(waterBalance.active_kc)}
/>
)}
</Box>
)}
{plan.warning && (
<Box
className='mt-4 p-4 rounded-2xl'
className="mt-4 p-4 rounded-2xl"
sx={{
background: 'linear-gradient(135deg, rgba(251, 191, 36, 0.12) 0%, rgba(245, 158, 11, 0.08) 100%)',
border: '1px solid rgba(251, 191, 36, 0.35)'
background:
"linear-gradient(135deg, rgba(251, 191, 36, 0.12) 0%, rgba(245, 158, 11, 0.08) 100%)",
border: "1px solid rgba(251, 191, 36, 0.35)",
}}
>
<Box className='flex gap-2'>
<i className='tabler-alert-triangle text-xl text-amber-600 mt-0.5 shrink-0' />
<Box className="flex gap-2">
<i className="tabler-alert-triangle text-xl text-amber-600 mt-0.5 shrink-0" />
<Box>
<Typography variant='subtitle2' fontWeight={600} color='warning.dark' className='mbe-1'>
{t('result.smartWarning')}
<Typography
variant="subtitle2"
fontWeight={600}
color="warning.dark"
className="mbe-1"
>
{t("result.smartWarning")}
</Typography>
<Typography variant='body2' color='text.secondary'>
<Typography variant="body2" color="text.secondary">
{plan.warning}
</Typography>
</Box>
@@ -296,25 +490,25 @@ export default function SmartIrrigationRecommendation() {
{loading && (
<Card
elevation={0}
className='mb-6'
className="mb-6"
sx={{
borderRadius: '24px',
borderRadius: "24px",
background: `linear-gradient(160deg, ${paperBg} 0%, ${alpha(primaryMain, 0.06)} 100%)`,
boxShadow: `0 8px 32px ${alpha(primaryMain, 0.1)}`,
border: `1px solid ${alpha(primaryMain, 0.12)}`
border: `1px solid ${alpha(primaryMain, 0.12)}`,
}}
>
<CardContent className='p-12 flex flex-col items-center gap-4'>
<CircularProgress size={48} sx={{ color: 'primary.main' }} />
<Typography variant='body2' color='text.secondary'>
{t('generating')}
<CardContent className="p-12 flex flex-col items-center gap-4">
<CircularProgress size={48} sx={{ color: "primary.main" }} />
<Typography variant="body2" color="text.secondary">
{statusMessage ?? t("generating")}
</Typography>
</CardContent>
</Card>
)}
</Box>
</Box>
)
);
}
// ─── Sub-components ──────────────────────────────────────────────────────────
@@ -322,116 +516,137 @@ export default function SmartIrrigationRecommendation() {
function FarmBadge({
icon,
label,
value
value,
}: {
icon: string
label: string
value: string
icon: string;
label: string;
value: string;
}) {
const theme = useTheme()
const primaryMain = theme.palette.primary.main
const theme = useTheme();
const primaryMain = theme.palette.primary.main;
return (
<Box
className='flex items-center gap-2 px-4 py-2.5 rounded-2xl transition-transform duration-200 hover:scale-[1.02]'
className="flex items-center gap-2 px-4 py-2.5 rounded-2xl transition-transform duration-200 hover:scale-[1.02]"
sx={{
background: `linear-gradient(145deg, ${alpha(primaryMain, 0.08)} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
border: `1px solid ${alpha(primaryMain, 0.15)}`,
boxShadow: 'inset 0 1px 2px rgba(255,255,255,0.5)'
boxShadow: "inset 0 1px 2px rgba(255,255,255,0.5)",
}}
>
<i className={`${icon} text-xl`} style={{ color: primaryMain }} />
<Box>
<Typography variant='caption' color='text.secondary' display='block' lineHeight={1.2}>
<Typography
variant="caption"
color="text.secondary"
display="block"
lineHeight={1.2}
>
{label}
</Typography>
<Typography variant='body2' fontWeight={600} color='text.primary'>
<Typography variant="body2" fontWeight={600} color="text.primary">
{value}
</Typography>
</Box>
</Box>
)
);
}
function CropCard({
crop,
label,
selected,
onClick
onClick,
}: {
crop: CropOption
label: string
selected: boolean
onClick: () => void
crop: CropOption;
label: string;
selected: boolean;
onClick: () => void;
}) {
const theme = useTheme()
const primaryMain = theme.palette.primary.main
const primaryDark = theme.palette.primary.dark
const paperBg = theme.palette.background.paper
const theme = useTheme();
const primaryMain = theme.palette.primary.main;
const primaryDark = theme.palette.primary.dark;
const paperBg = theme.palette.background.paper;
return (
<Card
component='button'
type='button'
component="button"
type="button"
elevation={0}
onClick={onClick}
className='flex items-center gap-3 px-4 py-3 rounded-2xl cursor-pointer transition-all duration-300 border-2 text-start'
className="flex items-center gap-3 px-4 py-3 rounded-2xl cursor-pointer transition-all duration-300 border-2 text-start"
sx={{
borderColor: selected ? primaryMain : 'transparent',
borderColor: selected ? primaryMain : "transparent",
background: selected
? `linear-gradient(145deg, ${alpha(primaryMain, 0.12)} 0%, ${alpha(primaryMain, 0.06)} 100%)`
: `linear-gradient(145deg, ${paperBg} 0%, ${alpha(primaryMain, 0.04)} 100%)`,
boxShadow: selected
? `0 4px 20px ${alpha(primaryMain, 0.2)}, inset 0 1px 0 rgba(255,255,255,0.8)`
: '0 2px 8px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.9)',
'&:hover': {
transform: 'translateY(-2px)',
: "0 2px 8px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.9)",
"&:hover": {
transform: "translateY(-2px)",
boxShadow: selected
? `0 6px 24px ${alpha(primaryMain, 0.25)}`
: `0 4px 16px ${alpha(primaryMain, 0.12)}`
}
: `0 4px 16px ${alpha(primaryMain, 0.12)}`,
},
}}
>
<Box
className='w-11 h-11 rounded-xl flex items-center justify-center shrink-0'
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0"
sx={{
background: selected
? `linear-gradient(135deg, ${primaryMain} 0%, ${primaryDark} 100%)`
: `linear-gradient(145deg, ${alpha(primaryMain, 0.1)} 0%, ${alpha(primaryMain, 0.05)} 100%)`
: `linear-gradient(145deg, ${alpha(primaryMain, 0.1)} 0%, ${alpha(primaryMain, 0.05)} 100%)`,
}}
>
<i className={`${crop.icon} text-xl ${selected ? 'text-white' : ''}`} style={!selected ? { color: primaryMain } : undefined} />
<i
className={`${crop.icon} text-xl ${selected ? "text-white" : ""}`}
style={!selected ? { color: primaryMain } : undefined}
/>
</Box>
<Typography variant='body2' fontWeight={600} color={selected ? 'primary.main' : 'text.primary'}>
<Typography
variant="body2"
fontWeight={600}
color={selected ? "primary.main" : "text.primary"}
>
{label}
</Typography>
{selected && (
<i className='tabler-circle-check-filled text-xl ms-auto' style={{ color: primaryMain }} />
<i
className="tabler-circle-check-filled text-xl ms-auto"
style={{ color: primaryMain }}
/>
)}
</Card>
)
);
}
function ResultRow({
icon,
label,
value
value,
}: {
icon: string
label: string
value: string
icon: string;
label: string;
value: string;
}) {
const theme = useTheme()
const primaryMain = theme.palette.primary.main
const theme = useTheme();
const primaryMain = theme.palette.primary.main;
return (
<Box className='flex items-center gap-4 p-3 rounded-2xl' sx={{ bgcolor: alpha(primaryMain, 0.06) }}>
<i className={`${icon} text-2xl shrink-0`} style={{ color: primaryMain }} />
<Box className='flex-1 min-w-0'>
<Typography variant='caption' color='text.secondary'>
<Box
className="flex items-center gap-4 p-3 rounded-2xl"
sx={{ bgcolor: alpha(primaryMain, 0.06) }}
>
<i
className={`${icon} text-2xl shrink-0`}
style={{ color: primaryMain }}
/>
<Box className="flex-1 min-w-0">
<Typography variant="caption" color="text.secondary">
{label}
</Typography>
<Typography variant='body1' fontWeight={600} color='text.primary'>
<Typography variant="body1" fontWeight={600} color="text.primary">
{value}
</Typography>
</Box>
</Box>
)
);
}