Files
Frontend/src/views/dashboards/farm/cropZoning/CropZoningWrapper.tsx
T
sajad-dev 5aea10a756 Enhance crop zoning features with new API integrations and UI updates
- Added Persian translations for legend levels in fa.json to improve localization.
- Updated CROP_ZONING_APIS.md to include new API endpoints for water need, soil quality, and cultivation risk, enhancing data retrieval capabilities.
- Refactored crop zoning components to support new data structures and improve rendering logic for different layers.
- Enhanced LayerControl and ZoneLegend components to dynamically display information based on the active layer, improving user experience.
- Implemented loading states and error handling in CropZoningWrapper for better data management during asynchronous operations.
2026-02-26 00:37:00 +03:30

301 lines
12 KiB
TypeScript

'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 {
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'
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'>
<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'
}
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]))
useEffect(() => {
cropZoningService
.getProducts()
.then(res => setProducts(res.products))
.catch(() => setProducts([]))
.finally(() => setProductsLoading(false))
}, [])
useEffect(() => {
setAreaLoading(true)
cropZoningService
.getArea()
.then(res => setAreaGeoJson(res.area as unknown as MapDrawGeoJSON))
.catch(() => setAreaGeoJson(null))
.finally(() => setAreaLoading(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))
}, [])
useEffect(() => {
if (isPolygon(areaGeoJson)) {
fetchZones(areaGeoJson)
} else {
setZonesData(null)
setZonesWaterNeed(null)
setZonesSoilQuality(null)
setZonesCultivationRisk(null)
}
}, [areaGeoJson, optimizationKey, fetchZones])
const gridForLayers = isPolygon(areaGeoJson)
? createGridFromPolygon(areaGeoJson as unknown as import('geojson').Feature<import('geojson').Polygon>)
: null
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])
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 (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: false
}))
}
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: false
}))
}
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: false
}))
}
return null
}, [activeLayer, zonesData, zonesWaterNeed, zonesSoilQuality, zonesCultivationRisk, products])
const handleAreaChange = useCallback((geojson: MapDrawGeoJSON) => {
setAreaGeoJson(geojson)
}, [])
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)
}, [])
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'>
{areaGeoJson ? (
<MapComponent
key='crop-zoning-map'
center={[35.6892, 51.389]}
zoom={13}
height='100%'
activeLayer={activeLayer}
onAreaChange={handleAreaChange}
onZoneClick={handleZoneClick}
optimizationKey={optimizationKey}
className='min-bs-[400px]'
initialAreaGeoJson={areaGeoJson}
zonesData={mapZonesData}
productLabels={productLabels}
readOnly
/>
) : (
<Box className='flex items-center justify-center bs-full min-bs-[400px] rounded-xl 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 }}
>
<CircularProgress size={48} />
</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>
)}
</Box>
<ZoneDetailPanel
open={panelOpen}
onClose={() => setPanelOpen(false)}
zone={selectedZone}
products={products}
loading={zoneDetailLoading}
/>
<CropZoningWeatherSection />
</Box>
)
}