UPDATE
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
+368
-246
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user