UPDATE
This commit is contained in:
@@ -152,6 +152,7 @@
|
||||
"farmAlerts": "هشدارهای مزرعه",
|
||||
"pestDiseaseRisk": "ریسک آفات و بیماری",
|
||||
"economicOverview": "نمای اقتصادی",
|
||||
"farmCalendar": "تقویم کشاورز",
|
||||
"sensorSection": "سنسورها",
|
||||
"sensor7In1": "سنسور خاک 7 در 1"
|
||||
},
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import CropHealthPageWrapper from '@views/dashboards/farm/CropHealthPageWrapper'
|
||||
|
||||
const CropHealthPage = async () => {
|
||||
return <CropHealthPageWrapper />
|
||||
}
|
||||
|
||||
export default CropHealthPage
|
||||
@@ -1,7 +0,0 @@
|
||||
import EconomicOverviewPageWrapper from '@views/dashboards/farm/EconomicOverviewPageWrapper'
|
||||
|
||||
const EconomicOverviewPage = async () => {
|
||||
return <EconomicOverviewPageWrapper />
|
||||
}
|
||||
|
||||
export default EconomicOverviewPage
|
||||
@@ -0,0 +1,7 @@
|
||||
import FarmerCalendarPage from '@views/dashboards/farm/FarmerCalendarPage'
|
||||
|
||||
const FarmerCalendar = async () => {
|
||||
return <FarmerCalendarPage />
|
||||
}
|
||||
|
||||
export default FarmerCalendar
|
||||
@@ -0,0 +1,409 @@
|
||||
# Yield & Harvest UI Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The `yield-harvest` page is designed as a two-part production dashboard for the farm domain:
|
||||
|
||||
1. **Interactive plant simulation section** for visual learning and scenario testing.
|
||||
2. **Yield and harvest analytics section** for practical production insights and KPI-based monitoring.
|
||||
|
||||
This page is rendered from `src/app/(dashboard)/(private)/yield-harvest/page.tsx` and uses `PlantProductionPage` as the top-level UI container.
|
||||
|
||||
---
|
||||
|
||||
## Page Structure
|
||||
|
||||
The page is vertically stacked and contains two full-width blocks:
|
||||
|
||||
### 1. Plant Simulator Block
|
||||
Located at the top of the page.
|
||||
|
||||
- Full-width section inside a responsive grid.
|
||||
- Intended to simulate plant growth visually and numerically.
|
||||
- Uses a split layout:
|
||||
- **Left column:** animated plant visualization and interactive controls.
|
||||
- **Right column:** growth charts, progress indicators, and explanatory text.
|
||||
|
||||
### 2. Yield & Harvest Analytics Block
|
||||
Located below the simulator.
|
||||
|
||||
- Full-width analytics area.
|
||||
- Includes:
|
||||
- KPI cards for yield-related summary numbers.
|
||||
- Harvest prediction card.
|
||||
- Yield prediction line chart.
|
||||
|
||||
This layout creates a natural flow from **simulation -> monitoring -> prediction**.
|
||||
|
||||
---
|
||||
|
||||
## Detailed UI Breakdown
|
||||
|
||||
## A. Plant Simulator Section
|
||||
|
||||
Source: `src/views/dashboards/farm/plantSimulator/PlantSimulator.tsx`
|
||||
|
||||
### A.1 Section Header
|
||||
- Large centered title with a plant emoji.
|
||||
- Strong visual emphasis to frame the simulator as the hero feature of the page.
|
||||
- Gives the page a more experimental and educational feel before the business analytics area.
|
||||
|
||||
### A.2 Left Column: Visual Plant Panel
|
||||
This side focuses on the animated growth representation.
|
||||
|
||||
#### Main visualization card
|
||||
- White card container with centered content.
|
||||
- Contains an SVG-based animated plant.
|
||||
- The plant evolves over time based on internal state:
|
||||
- height
|
||||
- leaves
|
||||
- branches
|
||||
- fruits
|
||||
- yield
|
||||
- yield rate
|
||||
|
||||
#### Stats mini-cards under the plant
|
||||
A responsive grid of small statistic cards shows the live simulation state:
|
||||
|
||||
- Plant height
|
||||
- Leaf count
|
||||
- Branch count
|
||||
- Fruit count
|
||||
- Total yield
|
||||
- Yield rate
|
||||
|
||||
UI characteristics:
|
||||
- Compact outlined cards
|
||||
- Numeric value on top
|
||||
- Label below
|
||||
- Different semantic colors for different metrics
|
||||
|
||||
#### Max growth state
|
||||
- When the plant reaches maximum growth, a warning-colored animated status text appears.
|
||||
- This creates a clear end-state in the simulation flow.
|
||||
|
||||
### A.3 Left Column: Controls Card
|
||||
Below the visualization card there is a separate control panel.
|
||||
|
||||
#### Action buttons
|
||||
- **Start / Stop** button
|
||||
- Changes color depending on state.
|
||||
- Green when ready to start.
|
||||
- Red when running.
|
||||
- **Reset** button
|
||||
- Secondary outlined style.
|
||||
- Stops simulation and resets all values.
|
||||
|
||||
#### Sliders / range controls
|
||||
Three control groups are shown as simple horizontal sliders:
|
||||
|
||||
- Growth speed
|
||||
- Light percentage
|
||||
- Water percentage
|
||||
|
||||
Each control has:
|
||||
- left-aligned label
|
||||
- right-aligned current numeric value
|
||||
- full-width slider below
|
||||
|
||||
This gives the simulator a lightweight lab-tool feel.
|
||||
|
||||
#### Effective rate box
|
||||
- Small outlined summary card at the bottom of controls.
|
||||
- Shows the calculated effective growth rate.
|
||||
- Important because it explains how environment and speed combine into the final growth behavior.
|
||||
|
||||
### A.4 Right Column: Growth Chart Panel
|
||||
The right side is a large analytics card for simulation history.
|
||||
|
||||
#### Main chart area
|
||||
- Displays history for:
|
||||
- height
|
||||
- leaves
|
||||
- yield
|
||||
- yield rate
|
||||
- This section is the analytical companion to the animated plant.
|
||||
- Helps the user compare visual growth with numeric changes over time.
|
||||
|
||||
#### Progress cards row
|
||||
Below the chart there are four compact progress status cards:
|
||||
|
||||
- Growth progress
|
||||
- Light status
|
||||
- Water status
|
||||
- Yield status
|
||||
|
||||
Each card includes:
|
||||
- a small title
|
||||
- a progress bar
|
||||
- a highlighted numeric value
|
||||
|
||||
This row acts as a quick operational summary for the simulator.
|
||||
|
||||
#### Explanatory description card
|
||||
- A final outlined text card explains the behavior of the simulator.
|
||||
- Works as a help/description area and lowers cognitive load for first-time users.
|
||||
|
||||
### A.5 UX Role of the Simulator
|
||||
The simulator is not only decorative; it shapes the page identity.
|
||||
|
||||
Its UI purpose is to:
|
||||
- create a more engaging entry point for the user
|
||||
- support experimentation with light/water/growth speed
|
||||
- visually communicate crop development
|
||||
- make the production dashboard feel more intelligent and interactive
|
||||
|
||||
---
|
||||
|
||||
## B. Yield & Harvest Analytics Section
|
||||
|
||||
Source: `src/views/dashboards/farm/YieldHarvestPageWrapper.tsx`
|
||||
|
||||
This section is data-driven and loads summary information based on the selected farm.
|
||||
|
||||
### B.1 Loading State
|
||||
Before content is available:
|
||||
- a centered circular loader is shown
|
||||
- the layout remains minimal and clean
|
||||
- this avoids rendering broken cards before farm data is ready
|
||||
|
||||
### B.2 KPI Row
|
||||
Source component: `src/views/dashboards/farm/FarmOverviewKPIs.tsx`
|
||||
|
||||
If yield prediction KPI data exists, a responsive KPI row is displayed.
|
||||
|
||||
#### KPI card behavior
|
||||
- Each KPI is rendered as a vertical statistics card.
|
||||
- Cards adapt to the number of items.
|
||||
- Common information shown in each card:
|
||||
- title
|
||||
- subtitle
|
||||
- main statistic
|
||||
- avatar icon
|
||||
- avatar color
|
||||
- optional chip badge
|
||||
|
||||
#### Visual style
|
||||
- Dashboard-style summary cards
|
||||
- Strong scanability
|
||||
- Good at communicating top-level performance in one glance
|
||||
|
||||
#### UX purpose
|
||||
This row acts as the executive summary of the page before the user goes deeper into charts and detailed predictions.
|
||||
|
||||
### B.3 Bottom Analytics Row
|
||||
This row is split into two cards:
|
||||
|
||||
- **Left:** Harvest prediction card
|
||||
- **Right:** Yield prediction chart
|
||||
|
||||
The ratio is approximately:
|
||||
- 4 columns for harvest prediction
|
||||
- 8 columns for chart insight
|
||||
|
||||
This gives the chart more space while preserving a compact side card for the date-based prediction.
|
||||
|
||||
---
|
||||
|
||||
## C. Harvest Prediction Card
|
||||
|
||||
Source: `src/views/dashboards/farm/HarvestPredictionCard.tsx`
|
||||
|
||||
### C.1 Card header
|
||||
The header includes:
|
||||
- success-colored avatar with calendar icon
|
||||
- title for harvest prediction
|
||||
- AI-estimated date subheader
|
||||
- option menu on the right
|
||||
|
||||
The option menu suggests actions such as:
|
||||
- details
|
||||
- adjust
|
||||
- export
|
||||
|
||||
This makes the card feel actionable rather than static.
|
||||
|
||||
### C.2 Main content
|
||||
The content is vertically spaced and intentionally minimal.
|
||||
|
||||
It includes:
|
||||
- large harvest date text as the focal point
|
||||
- optional chip showing remaining days until harvest
|
||||
- secondary description text explaining the context
|
||||
|
||||
### C.3 UI role
|
||||
This card is designed to answer one critical user question fast:
|
||||
|
||||
**"When is the expected harvest date?"**
|
||||
|
||||
Because of that, the hierarchy is strong:
|
||||
- date first
|
||||
- countdown second
|
||||
- explanation third
|
||||
|
||||
---
|
||||
|
||||
## D. Yield Prediction Chart
|
||||
|
||||
Source: `src/views/dashboards/farm/YieldPredictionChart.tsx`
|
||||
|
||||
### D.1 Card header
|
||||
The chart card header includes:
|
||||
- title for yield prediction chart
|
||||
- subheader comparing current year vs previous year
|
||||
- option menu with actions like export, compare, and details
|
||||
|
||||
This makes the chart feel like a decision-support tool.
|
||||
|
||||
### D.2 Chart body
|
||||
- Uses an Apex line chart.
|
||||
- Smooth line strokes.
|
||||
- Top legend.
|
||||
- Two major comparison colors:
|
||||
- primary
|
||||
- success
|
||||
- Dashed grid lines for a cleaner analytics look.
|
||||
- X-axis categories typically represent months.
|
||||
- Y-axis values are formatted in tons.
|
||||
- Tooltip values are also formatted in tons.
|
||||
|
||||
### D.3 Summary list under the chart
|
||||
If summary items exist, they appear below the chart as stacked summary rows.
|
||||
|
||||
Each row includes:
|
||||
- rounded avatar with icon
|
||||
- title
|
||||
- subtitle
|
||||
- highlighted amount value on the far side
|
||||
|
||||
This creates a hybrid layout:
|
||||
- chart for trend analysis
|
||||
- summary rows for quick interpretation
|
||||
|
||||
### D.4 UI role
|
||||
This component gives the page its strongest analytical identity.
|
||||
It helps answer:
|
||||
- How is production trending?
|
||||
- How does this year compare to another reference line?
|
||||
- What supporting summary metrics matter most?
|
||||
|
||||
---
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
The page is designed with responsive grid behavior.
|
||||
|
||||
### On large screens
|
||||
- Plant simulator is split into left/right columns.
|
||||
- Harvest card and yield chart sit side by side.
|
||||
- KPI cards spread horizontally.
|
||||
|
||||
### On medium and small screens
|
||||
- Cards stack vertically.
|
||||
- Harvest prediction moves above the chart.
|
||||
- Simulator sections become more linear and easier to scroll.
|
||||
- KPI cards adapt their width based on count.
|
||||
|
||||
This keeps the page usable on desktop, tablet, and mobile.
|
||||
|
||||
---
|
||||
|
||||
## Visual Hierarchy Summary
|
||||
|
||||
The UI hierarchy of `yield-harvest` is:
|
||||
|
||||
1. **Interactive growth simulator** as the experiential top section
|
||||
2. **KPI summary cards** for immediate production status
|
||||
3. **Harvest date card** for the key operational decision
|
||||
4. **Yield trend chart** for deeper forecasting insight
|
||||
|
||||
This structure is effective because it balances:
|
||||
- exploration
|
||||
- summary
|
||||
- actionability
|
||||
- forecasting
|
||||
|
||||
---
|
||||
|
||||
## UX Strengths of the Current Page
|
||||
|
||||
### Strong points
|
||||
- Combines simulation and analytics in one page
|
||||
- Good mix of visual and numeric feedback
|
||||
- Clear card-based dashboard structure
|
||||
- Strong use of progressive disclosure
|
||||
- Easy scanning of important information
|
||||
- Responsive composition with logical stacking
|
||||
|
||||
### Product feeling
|
||||
The page feels like a mix of:
|
||||
- agronomy lab tool
|
||||
- farm operations dashboard
|
||||
- lightweight forecasting center
|
||||
|
||||
---
|
||||
|
||||
## Suggested Future UI Improvements
|
||||
|
||||
If this page is expanded later, the following improvements would fit naturally:
|
||||
|
||||
### 1. Section labels or anchors
|
||||
Add explicit section separators such as:
|
||||
- Plant Simulation
|
||||
- Production Summary
|
||||
- Harvest Forecast
|
||||
|
||||
### 2. Farm-specific header
|
||||
A top hero strip could include:
|
||||
- selected farm name
|
||||
- crop type
|
||||
- current season
|
||||
- last updated time
|
||||
|
||||
### 3. Chart filters
|
||||
Add filters above the yield chart for:
|
||||
- crop type
|
||||
- year range
|
||||
- unit switching
|
||||
- predicted vs actual mode
|
||||
|
||||
### 4. Harvest readiness indicator
|
||||
A radial progress or readiness gauge near the harvest prediction date could improve urgency perception.
|
||||
|
||||
### 5. Scenario presets in simulator
|
||||
Quick buttons such as:
|
||||
- Low water
|
||||
- High sunlight
|
||||
- Optimal growth
|
||||
would make the simulator easier to test.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
Main page entry:
|
||||
- `src/app/(dashboard)/(private)/yield-harvest/page.tsx`
|
||||
|
||||
Top-level page composition:
|
||||
- `src/views/dashboards/farm/PlantProductionPage.tsx`
|
||||
|
||||
Simulator:
|
||||
- `src/views/dashboards/farm/plantSimulator/PlantSimulator.tsx`
|
||||
|
||||
Yield & harvest analytics wrapper:
|
||||
- `src/views/dashboards/farm/YieldHarvestPageWrapper.tsx`
|
||||
|
||||
Analytics components:
|
||||
- `src/views/dashboards/farm/FarmOverviewKPIs.tsx`
|
||||
- `src/views/dashboards/farm/HarvestPredictionCard.tsx`
|
||||
- `src/views/dashboards/farm/YieldPredictionChart.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Final Summary
|
||||
|
||||
The `yield-harvest` page UI is a full production intelligence screen.
|
||||
|
||||
It starts with an interactive, highly visual plant-growth simulator and then transitions into a more standard analytics dashboard for yield and harvest planning. The page is especially strong in turning agricultural concepts into understandable interface blocks: simulation, KPIs, predicted date, and long-term yield trend.
|
||||
|
||||
In short, the UI is not just a reporting surface; it is a combined **simulation + monitoring + forecasting** experience for farm production.
|
||||
@@ -95,9 +95,6 @@ const VerticalMenu = ({ scrollMenu }: Props) => {
|
||||
{t('dashboards')}
|
||||
</MenuItem>
|
||||
<MenuSection label={t('farmDomain')}>
|
||||
<MenuItem href="/crop-health" icon={<i className="tabler-plant" />}>
|
||||
{t('cropHealth')}
|
||||
</MenuItem>
|
||||
<MenuItem href="/yield-harvest" icon={<i className="tabler-chart-line" />}>
|
||||
{t('yieldHarvest')}
|
||||
</MenuItem>
|
||||
@@ -107,8 +104,8 @@ const VerticalMenu = ({ scrollMenu }: Props) => {
|
||||
<MenuItem href="/pest-risk" icon={<i className="tabler-bug" />}>
|
||||
{t('pestDiseaseRisk')}
|
||||
</MenuItem>
|
||||
<MenuItem href="/economic-overview" icon={<i className="tabler-currency-dollar" />}>
|
||||
{t('economicOverview')}
|
||||
<MenuItem href="/farmer-calendar" icon={<i className="tabler-calendar-event" />}>
|
||||
{t('farmCalendar')}
|
||||
</MenuItem>
|
||||
</MenuSection>
|
||||
<MenuSection label={t('dataSection')}>
|
||||
|
||||
@@ -4,9 +4,16 @@ export const navigationLabels = {
|
||||
farm: 'داشبورد مزرعه',
|
||||
waterData: 'دیتاهای آب',
|
||||
soilData: 'اطلاعات خاک',
|
||||
cropZoning: 'زونبندی کشت',
|
||||
sensorSection: 'سنسورها',
|
||||
sensor7In1: 'سنسور خاک 7 در 1',
|
||||
dataSection: 'بخش دادهها',
|
||||
recommendation: 'توصیهها',
|
||||
irrigationRecommendation: 'توصیه آبیاری',
|
||||
fertilizationRecommendation: 'توصیه کوددهی',
|
||||
aiAssistant: 'دستیار هوشمند',
|
||||
farmAiAssistant: 'دستیار هوشمند مزرعه',
|
||||
farmCalendar: 'تقویم کشاورز',
|
||||
crm: 'مدیریت ارتباط با مشتری',
|
||||
analytics: 'تحلیلها',
|
||||
eCommerce: 'فروشگاه',
|
||||
@@ -109,4 +116,3 @@ export const navigationLabels = {
|
||||
menuLevel3: 'سطح منو 3',
|
||||
disabledMenu: 'منوی غیرفعال'
|
||||
}
|
||||
|
||||
|
||||
@@ -110,8 +110,18 @@
|
||||
"farmAlerts": "تنبيهات المزرعة",
|
||||
"pestDiseaseRisk": "مخاطر الآفات والأمراض",
|
||||
"economicOverview": "النظرة الاقتصادية",
|
||||
"farmCalendar": "تقويم المزارع",
|
||||
"dataSection": "قسم البيانات",
|
||||
"waterData": "بيانات المياه",
|
||||
"soilData": "بيانات التربة",
|
||||
"cropZoning": "تقسيم المحاصيل",
|
||||
"sensorSection": "المستشعرات",
|
||||
"sensor7In1": "مستشعر التربة 7 في 1"
|
||||
"sensor7In1": "مستشعر التربة 7 في 1",
|
||||
"recommendation": "التوصيات",
|
||||
"irrigationRecommendation": "توصية الري",
|
||||
"fertilizationRecommendation": "توصية التسميد",
|
||||
"aiAssistant": "المساعد الذكي",
|
||||
"farmAiAssistant": "مساعد المزرعة الذكي"
|
||||
},
|
||||
"irrigation": {
|
||||
"title": "توصية الري الذكية",
|
||||
|
||||
@@ -110,8 +110,18 @@
|
||||
"farmAlerts": "Farm Alerts",
|
||||
"pestDiseaseRisk": "Pest & Disease Risk",
|
||||
"economicOverview": "Economic Overview",
|
||||
"farmCalendar": "Farmer Calendar",
|
||||
"dataSection": "Data Section",
|
||||
"waterData": "Water Data",
|
||||
"soilData": "Soil Data",
|
||||
"cropZoning": "Crop Zoning",
|
||||
"sensorSection": "Sensors",
|
||||
"sensor7In1": "Soil Sensor 7-in-1"
|
||||
"sensor7In1": "Soil Sensor 7-in-1",
|
||||
"recommendation": "Recommendations",
|
||||
"irrigationRecommendation": "Irrigation Recommendation",
|
||||
"fertilizationRecommendation": "Fertilization Recommendation",
|
||||
"aiAssistant": "AI Assistant",
|
||||
"farmAiAssistant": "Farm AI Assistant"
|
||||
},
|
||||
"irrigation": {
|
||||
"title": "Smart Irrigation Recommendation",
|
||||
|
||||
@@ -110,8 +110,18 @@
|
||||
"farmAlerts": "هشدارهای مزرعه",
|
||||
"pestDiseaseRisk": "ریسک آفات و بیماری",
|
||||
"economicOverview": "نمای اقتصادی",
|
||||
"farmCalendar": "تقویم کشاورز",
|
||||
"dataSection": "بخش دادهها",
|
||||
"waterData": "دیتاهای آب",
|
||||
"soilData": "اطلاعات خاک",
|
||||
"cropZoning": "زونبندی کشت",
|
||||
"sensorSection": "سنسورها",
|
||||
"sensor7In1": "سنسور خاک 7 در 1"
|
||||
"sensor7In1": "سنسور خاک 7 در 1",
|
||||
"recommendation": "توصیهها",
|
||||
"irrigationRecommendation": "توصیه آبیاری",
|
||||
"fertilizationRecommendation": "توصیه کوددهی",
|
||||
"aiAssistant": "دستیار هوشمند",
|
||||
"farmAiAssistant": "دستیار هوشمند مزرعه"
|
||||
},
|
||||
"irrigation": {
|
||||
"title": "توصیه آبیاری هوشمند",
|
||||
|
||||
@@ -110,8 +110,18 @@
|
||||
"farmAlerts": "Alertes ferme",
|
||||
"pestDiseaseRisk": "Risque ravageurs et maladies",
|
||||
"economicOverview": "Aperçu économique",
|
||||
"farmCalendar": "Calendrier de l'agriculteur",
|
||||
"dataSection": "Section des données",
|
||||
"waterData": "Données sur l'eau",
|
||||
"soilData": "Données du sol",
|
||||
"cropZoning": "Zonage des cultures",
|
||||
"sensorSection": "Capteurs",
|
||||
"sensor7In1": "Capteur de sol 7-en-1"
|
||||
"sensor7In1": "Capteur de sol 7-en-1",
|
||||
"recommendation": "Recommandations",
|
||||
"irrigationRecommendation": "Recommandation d'irrigation",
|
||||
"fertilizationRecommendation": "Recommandation de fertilisation",
|
||||
"aiAssistant": "Assistant intelligent",
|
||||
"farmAiAssistant": "Assistant IA agricole"
|
||||
},
|
||||
"irrigation": {
|
||||
"title": "Recommandation intelligente d'irrigation",
|
||||
|
||||
@@ -45,11 +45,6 @@ const horizontalMenuData = (): HorizontalMenuDataType[] => [
|
||||
icon: 'tabler-dashboard',
|
||||
href: '/dashboard'
|
||||
},
|
||||
{
|
||||
label: 'cropHealth',
|
||||
icon: 'tabler-plant',
|
||||
href: '/crop-health'
|
||||
},
|
||||
{
|
||||
label: 'waterWeather',
|
||||
icon: 'tabler-droplet',
|
||||
@@ -76,9 +71,9 @@ const horizontalMenuData = (): HorizontalMenuDataType[] => [
|
||||
href: '/pest-risk'
|
||||
},
|
||||
{
|
||||
label: 'economicOverview',
|
||||
icon: 'tabler-currency-dollar',
|
||||
href: '/economic-overview'
|
||||
label: 'farmCalendar',
|
||||
icon: 'tabler-calendar-event',
|
||||
href: '/farmer-calendar'
|
||||
},
|
||||
{
|
||||
label: 'cropZoning',
|
||||
@@ -101,11 +96,6 @@ const horizontalMenuData = (): HorizontalMenuDataType[] => [
|
||||
icon: 'tabler-dashboard',
|
||||
href: '/dashboard'
|
||||
},
|
||||
{
|
||||
label: 'cropHealth',
|
||||
icon: 'tabler-plant',
|
||||
href: '/crop-health'
|
||||
},
|
||||
{
|
||||
label: 'waterWeather',
|
||||
icon: 'tabler-droplet',
|
||||
@@ -132,9 +122,9 @@ const horizontalMenuData = (): HorizontalMenuDataType[] => [
|
||||
href: '/pest-risk'
|
||||
},
|
||||
{
|
||||
label: 'economicOverview',
|
||||
icon: 'tabler-currency-dollar',
|
||||
href: '/economic-overview'
|
||||
label: 'farmCalendar',
|
||||
icon: 'tabler-calendar-event',
|
||||
href: '/farmer-calendar'
|
||||
},
|
||||
{
|
||||
label: 'cropZoning',
|
||||
|
||||
@@ -49,11 +49,6 @@ const verticalMenuData = (): VerticalMenuDataType[] => [
|
||||
icon: 'tabler-dashboard',
|
||||
href: '/dashboard'
|
||||
},
|
||||
{
|
||||
label: 'cropHealth',
|
||||
icon: 'tabler-plant',
|
||||
href: '/crop-health'
|
||||
},
|
||||
{
|
||||
label: 'waterWeather',
|
||||
icon: 'tabler-droplet',
|
||||
@@ -80,9 +75,9 @@ const verticalMenuData = (): VerticalMenuDataType[] => [
|
||||
href: '/pest-risk'
|
||||
},
|
||||
{
|
||||
label: 'economicOverview',
|
||||
icon: 'tabler-currency-dollar',
|
||||
href: '/economic-overview'
|
||||
label: 'farmCalendar',
|
||||
icon: 'tabler-calendar-event',
|
||||
href: '/farmer-calendar'
|
||||
},
|
||||
{
|
||||
label: 'cropZoning',
|
||||
|
||||
@@ -2,10 +2,73 @@ import { apiClient } from '../client'
|
||||
|
||||
const PREFIX = '/api/farm-alerts'
|
||||
|
||||
export interface FarmAlertsSummary {
|
||||
tracker?: Record<string, unknown>
|
||||
timeline?: Record<string, unknown>
|
||||
recommendations?: Record<string, unknown>
|
||||
export interface FarmAlertRequestItem {
|
||||
alert_id: string
|
||||
level: string
|
||||
title: string
|
||||
message: string
|
||||
suggested_action?: string
|
||||
source_metric_type?: string
|
||||
timestamp?: string | null
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface FarmAlertTrackerItem {
|
||||
metric_type?: string
|
||||
title?: string
|
||||
current_value?: number
|
||||
threshold_value?: number
|
||||
severity?: string
|
||||
duration?: string
|
||||
timestamp?: string
|
||||
domain?: string
|
||||
unit?: string
|
||||
icon?: string
|
||||
summary?: string
|
||||
recommended_action?: string
|
||||
explanation?: string
|
||||
}
|
||||
|
||||
export interface FarmAlertNotificationItem {
|
||||
id: number
|
||||
uuid: string
|
||||
farm_uuid: string
|
||||
since_id: number
|
||||
endpoint?: string
|
||||
title: string
|
||||
message: string
|
||||
level: string
|
||||
suggested_action?: string
|
||||
source_alert_id?: string
|
||||
source_metric_type?: string
|
||||
payload?: Record<string, unknown>
|
||||
is_read: boolean
|
||||
metadata?: Record<string, unknown>
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface FarmAlertsTrackerPayload {
|
||||
totalAlerts?: number
|
||||
alerts?: FarmAlertTrackerItem[]
|
||||
alertStats?: Array<Record<string, unknown>>
|
||||
alertClusters?: Array<Record<string, unknown>>
|
||||
mostCriticalIssue?: FarmAlertTrackerItem | null
|
||||
prioritizedAlertSummaries?: string[]
|
||||
recommendedOperationalActions?: string[]
|
||||
humanReadableExplanations?: string[]
|
||||
}
|
||||
|
||||
export interface FarmAlertsTrackerResponse {
|
||||
farm_uuid: string
|
||||
service_id: string
|
||||
tracker: FarmAlertsTrackerPayload
|
||||
headline?: string
|
||||
overview?: string
|
||||
status_level?: string
|
||||
notifications?: FarmAlertNotificationItem[]
|
||||
raw_llm_response?: string
|
||||
structured_context?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
@@ -25,9 +88,19 @@ function extract<T>(res: ApiResponse<T> | T): T {
|
||||
}
|
||||
|
||||
export const farmAlertsService = {
|
||||
async getTracker(farmUuid: string): Promise<Record<string, unknown>> {
|
||||
const res = await apiClient.get<ApiResponse<Record<string, unknown>> | Record<string, unknown>>(
|
||||
`${PREFIX}/tracker/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
||||
async analyzeTracker(
|
||||
payload: { farmUuid: string; alerts?: FarmAlertRequestItem[] },
|
||||
): Promise<FarmAlertsTrackerResponse> {
|
||||
const requestBody = {
|
||||
farm_uuid: payload.farmUuid,
|
||||
...(payload.alerts?.length ? { alerts: payload.alerts } : {}),
|
||||
}
|
||||
|
||||
const res = await apiClient.post<
|
||||
ApiResponse<FarmAlertsTrackerResponse> | FarmAlertsTrackerResponse
|
||||
>(
|
||||
`${PREFIX}/tracker/`,
|
||||
requestBody,
|
||||
)
|
||||
return extract(res)
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ const AppFullCalendar = styled('div')(({ theme }: { theme: Theme }) => ({
|
||||
position: 'relative',
|
||||
borderRadius: 'var(--mui-shape-borderRadius)',
|
||||
'& .fc': {
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
|
||||
'.fc-col-header, .fc-daygrid-body, .fc-scrollgrid-sync-table, .fc-timegrid-body, .fc-timegrid-body table': {
|
||||
|
||||
@@ -27,9 +27,13 @@ type CalenderProps = {
|
||||
calendarApi: any
|
||||
setCalendarApi: (val: any) => void
|
||||
calendarsColor: CalendarColors
|
||||
dispatch: AppDispatch
|
||||
handleLeftSidebarToggle: () => void
|
||||
handleAddEventSidebarToggle: () => void
|
||||
dispatch?: AppDispatch
|
||||
handleLeftSidebarToggle?: () => void
|
||||
handleAddEventSidebarToggle?: () => void
|
||||
editable?: boolean
|
||||
showSidebarToggle?: boolean
|
||||
onDateClick?: (date: Date) => void
|
||||
onEventClick?: (event: any) => void
|
||||
}
|
||||
|
||||
const blankEvent: AddEventType = {
|
||||
@@ -54,7 +58,11 @@ const Calendar = (props: CalenderProps) => {
|
||||
calendarsColor,
|
||||
dispatch,
|
||||
handleAddEventSidebarToggle,
|
||||
handleLeftSidebarToggle
|
||||
handleLeftSidebarToggle,
|
||||
editable = true,
|
||||
showSidebarToggle = true,
|
||||
onDateClick,
|
||||
onEventClick
|
||||
} = props
|
||||
|
||||
// Refs
|
||||
@@ -78,7 +86,7 @@ const Calendar = (props: CalenderProps) => {
|
||||
initialView: 'dayGridMonth',
|
||||
locale: 'fa',
|
||||
headerToolbar: {
|
||||
start: 'sidebarToggle,prev,next,title',
|
||||
start: `${showSidebarToggle ? 'sidebarToggle,' : ''}prev,next,title`,
|
||||
end: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
|
||||
},
|
||||
views: {
|
||||
@@ -153,7 +161,7 @@ const Calendar = (props: CalenderProps) => {
|
||||
Enable dragging and resizing event
|
||||
? Docs: https://fullcalendar.io/docs/editable
|
||||
*/
|
||||
editable: true,
|
||||
editable,
|
||||
|
||||
/*
|
||||
Enable resizing event from start
|
||||
@@ -192,8 +200,12 @@ const Calendar = (props: CalenderProps) => {
|
||||
eventClick({ event: clickedEvent, jsEvent }: any) {
|
||||
jsEvent.preventDefault()
|
||||
|
||||
onEventClick?.(clickedEvent)
|
||||
|
||||
if (dispatch && handleAddEventSidebarToggle) {
|
||||
dispatch(selectedEvent(clickedEvent))
|
||||
handleAddEventSidebarToggle()
|
||||
}
|
||||
|
||||
if (clickedEvent.url) {
|
||||
// Open the URL in a new tab
|
||||
@@ -210,12 +222,18 @@ const Calendar = (props: CalenderProps) => {
|
||||
sidebarToggle: {
|
||||
icon: 'tabler tabler-menu-2',
|
||||
click() {
|
||||
handleLeftSidebarToggle()
|
||||
handleLeftSidebarToggle?.()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
dateClick(info: any) {
|
||||
onDateClick?.(info.date)
|
||||
|
||||
if (!dispatch || !handleAddEventSidebarToggle) {
|
||||
return
|
||||
}
|
||||
|
||||
const ev = { ...blankEvent }
|
||||
|
||||
ev.start = info.date
|
||||
@@ -232,6 +250,10 @@ const Calendar = (props: CalenderProps) => {
|
||||
? We can use `eventDragStop` but it doesn't return updated event so we have to use `eventDrop` which returns updated event
|
||||
*/
|
||||
eventDrop({ event: droppedEvent }: any) {
|
||||
if (!dispatch) {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert FullCalendar event to API format
|
||||
const eventData = {
|
||||
start: droppedEvent.start ? new Date(droppedEvent.start).toISOString() : '',
|
||||
@@ -247,6 +269,10 @@ const Calendar = (props: CalenderProps) => {
|
||||
? Docs: https://fullcalendar.io/docs/eventResize
|
||||
*/
|
||||
eventResize({ event: resizedEvent }: any) {
|
||||
if (!dispatch) {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert FullCalendar event to API format
|
||||
const eventData = {
|
||||
start: resizedEvent.start ? new Date(resizedEvent.start).toISOString() : '',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useFarmHub } from '@/hooks/useFarmHub'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
import Grid from '@mui/material/Grid2'
|
||||
import Box from '@mui/material/Box'
|
||||
@@ -9,9 +10,15 @@ import CircularProgress from '@mui/material/CircularProgress'
|
||||
|
||||
import FarmAlertsTracker from '@views/dashboards/farm/FarmAlertsTracker'
|
||||
import FarmAlertsTimeline from '@views/dashboards/farm/FarmAlertsTimeline'
|
||||
import NotificationSettingsCard from '@views/dashboards/farm/NotificationSettingsCard'
|
||||
import RecommendationsList from '@views/dashboards/farm/RecommendationsList'
|
||||
|
||||
import { farmAlertsService } from '@/libs/api/services/farmAlertsService'
|
||||
import {
|
||||
farmAlertsService,
|
||||
type FarmAlertsTrackerResponse,
|
||||
type FarmAlertTrackerItem,
|
||||
type FarmAlertNotificationItem
|
||||
} from '@/libs/api/services/farmAlertsService'
|
||||
|
||||
const cardRowSx = {
|
||||
display: 'flex',
|
||||
@@ -20,6 +27,94 @@ const cardRowSx = {
|
||||
'& > *': { flex: 1, minHeight: 0 }
|
||||
}
|
||||
|
||||
const getSeverityColor = (value?: string): 'primary' | 'warning' | 'error' | 'info' | 'success' => {
|
||||
switch (value?.toLowerCase()) {
|
||||
case 'critical':
|
||||
case 'danger':
|
||||
case 'error':
|
||||
case 'high':
|
||||
return 'error'
|
||||
case 'warning':
|
||||
case 'medium':
|
||||
return 'warning'
|
||||
case 'success':
|
||||
return 'success'
|
||||
case 'low':
|
||||
case 'info':
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const buildTrackerCardData = (result: FarmAlertsTrackerResponse): Record<string, unknown> => {
|
||||
const tracker = result.tracker ?? {}
|
||||
const totalAlerts = tracker.totalAlerts ?? 0
|
||||
const alertStats = Array.isArray(tracker.alertStats) ? tracker.alertStats : []
|
||||
const safeTotal = Math.max(totalAlerts, 1)
|
||||
const criticalCount = (alertStats as Array<Record<string, unknown>>).reduce((sum, item) => {
|
||||
const severity = String(item.severity ?? '').toLowerCase()
|
||||
|
||||
return severity === 'high' || severity === 'critical' || severity === 'danger' ? sum + Number(item.count ?? 0) : sum
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
totalAlerts,
|
||||
alertStats: alertStats.map((item, index) => ({
|
||||
title: String(item.title ?? `Alert ${index + 1}`),
|
||||
count: String(item.count ?? '0'),
|
||||
avatarIcon: String(item.avatarIcon ?? 'tabler-alert-triangle'),
|
||||
avatarColor: getSeverityColor(String(item.severity ?? item.avatarColor ?? result.status_level)),
|
||||
})),
|
||||
radialBarValue: Math.min(Math.round((criticalCount / safeTotal) * 100), 100),
|
||||
}
|
||||
}
|
||||
|
||||
const buildTimelineData = (
|
||||
result: FarmAlertsTrackerResponse,
|
||||
notifications: FarmAlertNotificationItem[],
|
||||
): Record<string, unknown> => {
|
||||
const trackerAlerts = Array.isArray(result.tracker?.alerts) ? result.tracker.alerts : []
|
||||
|
||||
if (notifications.length > 0) {
|
||||
return {
|
||||
alerts: notifications.map(item => ({
|
||||
title: item.title,
|
||||
description: item.suggested_action || item.message,
|
||||
time: format(new Date(item.created_at), 'yyyy-MM-dd HH:mm'),
|
||||
color: getSeverityColor(item.level),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
alerts: trackerAlerts.map((item: FarmAlertTrackerItem, index: number) => ({
|
||||
title: item.title || `Alert ${index + 1}`,
|
||||
description: item.explanation || item.summary || item.recommended_action || '',
|
||||
time: item.duration || (item.timestamp ? format(new Date(item.timestamp), 'yyyy-MM-dd HH:mm') : '-'),
|
||||
color: getSeverityColor(item.severity || result.status_level),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const buildRecommendationsData = (result: FarmAlertsTrackerResponse): Record<string, unknown> => {
|
||||
const tracker = result.tracker ?? {}
|
||||
const actions = Array.isArray(tracker.recommendedOperationalActions)
|
||||
? tracker.recommendedOperationalActions
|
||||
: []
|
||||
const explanations = Array.isArray(tracker.humanReadableExplanations)
|
||||
? tracker.humanReadableExplanations
|
||||
: []
|
||||
|
||||
return {
|
||||
recommendations: actions.map((action, index) => ({
|
||||
title: `اقدام پیشنهادی ${index + 1}`,
|
||||
subtitle: explanations[index] || action,
|
||||
avatarIcon: 'tabler-arrow-up-right',
|
||||
avatarColor: getSeverityColor(result.status_level),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const AlertsPageWrapper = () => {
|
||||
const { farmHub } = useFarmHub()
|
||||
const farmUuid = farmHub?.farm_uuid
|
||||
@@ -38,15 +133,19 @@ const AlertsPageWrapper = () => {
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
farmAlertsService.getTracker(farmUuid).catch(() => ({})),
|
||||
farmAlertsService.getTimeline(farmUuid).catch(() => ({})),
|
||||
farmAlertsService.getRecommendations(farmUuid).catch(() => ({}))
|
||||
])
|
||||
.then(([t, tl, r]) => {
|
||||
setTracker(t)
|
||||
setTimeline(tl)
|
||||
setRecommendations(r)
|
||||
farmAlertsService
|
||||
.analyzeTracker({ farmUuid })
|
||||
.then(result => {
|
||||
const notifications = Array.isArray(result.notifications) ? result.notifications : []
|
||||
|
||||
setTracker(buildTrackerCardData(result))
|
||||
setTimeline(buildTimelineData(result, notifications))
|
||||
setRecommendations(buildRecommendationsData(result))
|
||||
})
|
||||
.catch(() => {
|
||||
setTracker({})
|
||||
setTimeline({})
|
||||
setRecommendations({})
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [farmUuid])
|
||||
@@ -73,6 +172,9 @@ const AlertsPageWrapper = () => {
|
||||
<RecommendationsList data={recommendations} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid size={12} sx={cardRowSx}>
|
||||
<NotificationSettingsCard />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useFarmHub } from '@/hooks/useFarmHub'
|
||||
|
||||
import Grid from '@mui/material/Grid2'
|
||||
import Box from '@mui/material/Box'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
|
||||
import FarmOverviewKPIs from '@views/dashboards/farm/FarmOverviewKPIs'
|
||||
import NDVIHealthCard from '@views/dashboards/farm/NDVIHealthCard'
|
||||
|
||||
import { cropHealthService } from '@/libs/api/services/cropHealthService'
|
||||
|
||||
const cardRowSx = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 380,
|
||||
'& > *': { flex: 1, minHeight: 0 }
|
||||
}
|
||||
|
||||
const CropHealthPageWrapper = () => {
|
||||
const { farmHub } = useFarmHub()
|
||||
const farmUuid = farmHub?.farm_uuid
|
||||
const [data, setData] = useState<Record<string, unknown>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!farmUuid) {
|
||||
setData({})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
cropHealthService
|
||||
.getSummary(farmUuid)
|
||||
.then(summary => setData((summary as Record<string, unknown>) ?? {}))
|
||||
.catch(() => setData({}))
|
||||
.finally(() => setLoading(false))
|
||||
}, [farmUuid])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display='flex' justifyContent='center' alignItems='center' minHeight={200}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box position='relative'>
|
||||
<Grid container spacing={6}>
|
||||
<Grid size={12} container spacing={6}>
|
||||
<FarmOverviewKPIs data={data.farm_health_score as Record<string, unknown>} />
|
||||
</Grid>
|
||||
<Grid size={12} sx={cardRowSx}>
|
||||
<NDVIHealthCard data={data.ndviHealthCard as Record<string, unknown>} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default CropHealthPageWrapper
|
||||
@@ -1,62 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useFarmHub } from '@/hooks/useFarmHub'
|
||||
|
||||
import Grid from '@mui/material/Grid2'
|
||||
import Box from '@mui/material/Box'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
|
||||
import EconomicOverview from '@views/dashboards/farm/EconomicOverview'
|
||||
|
||||
import { economicOverviewService } from '@/libs/api/services/economicOverviewService'
|
||||
import type { EconomicOverviewSummary } from '@/libs/api/services/economicOverviewService'
|
||||
|
||||
const cardRowSx = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 380,
|
||||
'& > *': { flex: 1, minHeight: 0 }
|
||||
}
|
||||
|
||||
const EconomicOverviewPageWrapper = () => {
|
||||
const { farmHub } = useFarmHub()
|
||||
const farmUuid = farmHub?.farm_uuid
|
||||
const [data, setData] = useState<EconomicOverviewSummary>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!farmUuid) {
|
||||
setData({})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
economicOverviewService
|
||||
.getSummary(farmUuid)
|
||||
.then(summary => setData(summary ?? {}))
|
||||
.catch(() => setData({}))
|
||||
.finally(() => setLoading(false))
|
||||
}, [farmUuid])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display='flex' justifyContent='center' alignItems='center' minHeight={200}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box position='relative'>
|
||||
<Grid container spacing={6}>
|
||||
<Grid size={12} sx={cardRowSx}>
|
||||
<EconomicOverview data={data.economicOverview as Record<string, unknown>} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default EconomicOverviewPageWrapper
|
||||
@@ -0,0 +1,486 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import type { EventInput } from '@fullcalendar/core'
|
||||
|
||||
import Box from '@mui/material/Box'
|
||||
import Card from '@mui/material/Card'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Grid from '@mui/material/Grid2'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import AppFullCalendar from '@/libs/styles/AppFullCalendar'
|
||||
import Calendar from '@views/apps/calendar/Calendar'
|
||||
import type { ThemeColor } from '@/@core/types'
|
||||
import type { CalendarColors, CalendarType } from '@/types/apps/calendarTypes'
|
||||
|
||||
const calendarColors: CalendarColors = {
|
||||
Personal: 'success',
|
||||
Business: 'warning',
|
||||
Family: 'primary',
|
||||
Holiday: 'info',
|
||||
ETC: 'error'
|
||||
}
|
||||
|
||||
const dayFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
})
|
||||
|
||||
const fullDateFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})
|
||||
|
||||
const makeDate = (year: number, month: number, day: number, hour = 8, minute = 0) =>
|
||||
new Date(year, month, day, hour, minute)
|
||||
|
||||
const today = new Date()
|
||||
const year = today.getFullYear()
|
||||
const month = today.getMonth()
|
||||
|
||||
const farmerEvents: EventInput[] = [
|
||||
{
|
||||
id: 'irrigation-1',
|
||||
title: 'آبیاری قطعه شمالی',
|
||||
start: makeDate(year, month, 4, 6, 30).toISOString(),
|
||||
end: makeDate(year, month, 4, 8, 0).toISOString(),
|
||||
extendedProps: {
|
||||
calendar: 'Personal',
|
||||
description: 'آبیاری قطره ای برای گوجه فرنگی ها با بررسی فشار خطوط قبل از شروع.'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'nutrition-1',
|
||||
title: 'کوددهی مزرعه ذرت',
|
||||
start: makeDate(year, month, 6, 9, 0).toISOString(),
|
||||
end: makeDate(year, month, 6, 11, 30).toISOString(),
|
||||
extendedProps: {
|
||||
calendar: 'Business',
|
||||
description: 'محلول پاشی مرحله رشد رویشی و ثبت مقدار مصرف برای هر هکتار.'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'scouting-1',
|
||||
title: 'بازدید آفات و بیماری',
|
||||
start: makeDate(year, month, 8, 7, 30).toISOString(),
|
||||
end: makeDate(year, month, 8, 9, 0).toISOString(),
|
||||
extendedProps: {
|
||||
calendar: 'ETC',
|
||||
description: 'بررسی لکه های برگی، جمع آوری نمونه و ثبت نقاط بحرانی در مزرعه.'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'harvest-1',
|
||||
title: 'برداشت آزمایشی زعفران',
|
||||
start: makeDate(year, month, 11, 5, 0).toISOString(),
|
||||
end: makeDate(year, month, 11, 9, 0).toISOString(),
|
||||
extendedProps: {
|
||||
calendar: 'Family',
|
||||
description: 'هماهنگی نیروی کار و ثبت عملکرد اولیه برای برنامه ریزی برداشت اصلی.'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'market-1',
|
||||
title: 'جلسه فروش با خریدار عمده',
|
||||
start: makeDate(year, month, 14, 12, 0).toISOString(),
|
||||
end: makeDate(year, month, 14, 13, 0).toISOString(),
|
||||
extendedProps: {
|
||||
calendar: 'Holiday',
|
||||
description: 'بررسی قیمت هفتگی، کیفیت محصول و زمان بندی تحویل بار.'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'irrigation-2',
|
||||
title: 'شست وشوی فیلترهای آبیاری',
|
||||
start: makeDate(year, month, 18, 6, 0).toISOString(),
|
||||
end: makeDate(year, month, 18, 7, 0).toISOString(),
|
||||
extendedProps: {
|
||||
calendar: 'Personal',
|
||||
description: 'سرویس پیشگیرانه برای جلوگیری از افت دبی در آبیاری نوبت بعد.'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'soil-1',
|
||||
title: 'نمونه برداری خاک',
|
||||
start: makeDate(year, month, 20, 8, 0).toISOString(),
|
||||
end: makeDate(year, month, 20, 10, 0).toISOString(),
|
||||
extendedProps: {
|
||||
calendar: 'Business',
|
||||
description: 'نمونه گیری از سه ناحیه برای ارسال به آزمایشگاه و تنظیم نسخه تغذیه.'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'maintenance-1',
|
||||
title: 'سرویس تراکتور و ادوات',
|
||||
start: makeDate(year, month, 23, 15, 0).toISOString(),
|
||||
end: makeDate(year, month, 23, 17, 0).toISOString(),
|
||||
extendedProps: {
|
||||
calendar: 'ETC',
|
||||
description: 'تعویض فیلتر روغن، گریس کاری و آماده سازی برای عملیات آخر هفته.'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const calendarStore: CalendarType = {
|
||||
events: farmerEvents,
|
||||
filteredEvents: farmerEvents,
|
||||
selectedEvent: null,
|
||||
selectedCalendars: ['Personal', 'Business', 'Family', 'Holiday', 'ETC']
|
||||
}
|
||||
|
||||
const overviewItems = [
|
||||
{
|
||||
label: 'کارهای این هفته',
|
||||
value: '۸ برنامه',
|
||||
note: '۳ مورد نیازمند پیگیری امروز',
|
||||
accent: 'linear-gradient(135deg, rgba(34,197,94,0.18), rgba(22,163,74,0.05))'
|
||||
},
|
||||
{
|
||||
label: 'اقدام بعدی',
|
||||
value: 'آبیاری شمال مزرعه',
|
||||
note: 'فردا، ساعت ۶:۳۰ صبح',
|
||||
accent: 'linear-gradient(135deg, rgba(14,165,233,0.18), rgba(2,132,199,0.05))'
|
||||
},
|
||||
{
|
||||
label: 'اولویت بحرانی',
|
||||
value: 'بازدید آفات',
|
||||
note: 'تا ۴۸ ساعت آینده انجام شود',
|
||||
accent: 'linear-gradient(135deg, rgba(249,115,22,0.18), rgba(234,88,12,0.05))'
|
||||
}
|
||||
]
|
||||
|
||||
const legendItems = [
|
||||
{ label: 'آبیاری و عملیات روزانه', color: 'success.main' },
|
||||
{ label: 'تغذیه و خاک', color: 'warning.main' },
|
||||
{ label: 'برداشت و نیروی کار', color: 'primary.main' },
|
||||
{ label: 'جلسات و هماهنگی فروش', color: 'info.main' },
|
||||
{ label: 'ریسک ها و نگهداری', color: 'error.main' }
|
||||
]
|
||||
|
||||
const getEventDate = (event: EventInput) => {
|
||||
const raw = event.start
|
||||
|
||||
if (raw instanceof Date) {
|
||||
return raw
|
||||
}
|
||||
|
||||
return raw ? new Date(raw) : new Date()
|
||||
}
|
||||
|
||||
const formatTimeRange = (event: EventInput) => {
|
||||
const start = getEventDate(event)
|
||||
const end =
|
||||
event.end instanceof Date ? event.end : event.end ? new Date(event.end) : null
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat('fa-IR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
return end
|
||||
? `${timeFormatter.format(start)} تا ${timeFormatter.format(end)}`
|
||||
: timeFormatter.format(start)
|
||||
}
|
||||
|
||||
const getCalendarLabel = (event: EventInput) => {
|
||||
const type = event.extendedProps?.calendar
|
||||
|
||||
switch (type) {
|
||||
case 'Personal':
|
||||
return 'آبیاری'
|
||||
case 'Business':
|
||||
return 'تغذیه'
|
||||
case 'Family':
|
||||
return 'برداشت'
|
||||
case 'Holiday':
|
||||
return 'بازار'
|
||||
default:
|
||||
return 'نگهداری'
|
||||
}
|
||||
}
|
||||
|
||||
const getCalendarChipColor = (event: EventInput): ThemeColor => {
|
||||
const type = event.extendedProps?.calendar as keyof CalendarColors | undefined
|
||||
|
||||
return type ? calendarColors[type] : 'secondary'
|
||||
}
|
||||
|
||||
const upcomingEvents = [...farmerEvents]
|
||||
.sort((left, right) => getEventDate(left).getTime() - getEventDate(right).getTime())
|
||||
.slice(0, 5)
|
||||
|
||||
const FarmerCalendarPage = () => {
|
||||
const [calendarApi, setCalendarApi] = useState<any>(null)
|
||||
const [selectedEvent, setSelectedEvent] = useState<EventInput>(upcomingEvents[0])
|
||||
|
||||
const selectedEventDate = useMemo(() => fullDateFormatter.format(getEventDate(selectedEvent)), [selectedEvent])
|
||||
|
||||
return (
|
||||
<Box className='flex flex-col gap-6'>
|
||||
<Card
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
color: 'common.white',
|
||||
background:
|
||||
'linear-gradient(135deg, #123524 0%, #1d5b3f 45%, #3f8f5d 100%)'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
insetInlineEnd: -40,
|
||||
insetBlockStart: -60,
|
||||
width: 220,
|
||||
height: 220,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.08)'
|
||||
}}
|
||||
/>
|
||||
<CardContent sx={{ p: { xs: 5, md: 8 } }}>
|
||||
<Grid container spacing={4} alignItems='center'>
|
||||
<Grid size={{ xs: 12, lg: 7 }}>
|
||||
<Stack spacing={2.5}>
|
||||
<Chip
|
||||
label='برنامه ریزی هوشمند مزرعه'
|
||||
color='success'
|
||||
variant='filled'
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
bgcolor: 'rgba(255,255,255,0.14)',
|
||||
color: 'common.white',
|
||||
fontWeight: 700
|
||||
}}
|
||||
/>
|
||||
<Typography variant='h3' sx={{ color: 'inherit', maxWidth: 720 }}>
|
||||
تقویم بزرگ عملیات کشاورز برای مدیریت آبیاری، تغذیه، برداشت و کارهای روزانه
|
||||
</Typography>
|
||||
<Typography variant='body1' sx={{ color: 'rgba(255,255,255,0.82)', maxWidth: 680 }}>
|
||||
این صفحه تمام برنامه های مزرعه را در یک نمای ماهانه جمع می کند تا کشاورز بداند
|
||||
امروز چه کاری مهم تر است و در روزهای آینده چه چیزی باید آماده شود.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, lg: 5 }}>
|
||||
<Grid container spacing={3}>
|
||||
{overviewItems.map(item => (
|
||||
<Grid key={item.label} size={{ xs: 12, md: 4, lg: 12 }}>
|
||||
<Card
|
||||
variant='outlined'
|
||||
sx={{
|
||||
height: '100%',
|
||||
borderColor: 'rgba(255,255,255,0.12)',
|
||||
background: item.accent,
|
||||
backdropFilter: 'blur(6px)',
|
||||
color: 'common.white'
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant='body2' sx={{ color: 'rgba(255,255,255,0.74)' }}>
|
||||
{item.label}
|
||||
</Typography>
|
||||
<Typography variant='h5' sx={{ color: 'inherit', my: 1.5 }}>
|
||||
{item.value}
|
||||
</Typography>
|
||||
<Typography variant='body2' sx={{ color: 'rgba(255,255,255,0.74)' }}>
|
||||
{item.note}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Grid container spacing={6}>
|
||||
<Grid size={{ xs: 12, xl: 8.5 }}>
|
||||
<Card className='overflow-visible'>
|
||||
<CardContent sx={{ p: { xs: 4, md: 6 } }}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: 'flex-start', md: 'center' }}
|
||||
justifyContent='space-between'
|
||||
sx={{ mb: 4 }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant='h5'>تقویم عملیات مزرعه</Typography>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
روی هر برنامه کلیک کن تا جزئیات آن در پنل کناری نمایش داده شود.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction='row' spacing={1.5} flexWrap='wrap' useFlexGap>
|
||||
{legendItems.map(item => (
|
||||
<Stack key={item.label} direction='row' spacing={1} alignItems='center'>
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: item.color
|
||||
}}
|
||||
/>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
{item.label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<AppFullCalendar
|
||||
className='app-calendar'
|
||||
sx={{
|
||||
'& .fc': {
|
||||
'& .fc-view-harness': {
|
||||
minHeight: { xs: 620, md: 760 }
|
||||
},
|
||||
'& .fc-toolbar-title': {
|
||||
fontSize: { xs: '1.2rem', md: '1.8rem' }
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Calendar
|
||||
calendarStore={calendarStore}
|
||||
calendarApi={calendarApi}
|
||||
setCalendarApi={setCalendarApi}
|
||||
calendarsColor={calendarColors}
|
||||
editable={false}
|
||||
showSidebarToggle={false}
|
||||
onEventClick={event => setSelectedEvent(event.toPlainObject())}
|
||||
onDateClick={date => {
|
||||
const nearest =
|
||||
[...farmerEvents].find(event => {
|
||||
const eventDate = getEventDate(event)
|
||||
|
||||
return (
|
||||
eventDate.getFullYear() === date.getFullYear() &&
|
||||
eventDate.getMonth() === date.getMonth() &&
|
||||
eventDate.getDate() === date.getDate()
|
||||
)
|
||||
}) || selectedEvent
|
||||
|
||||
setSelectedEvent(nearest)
|
||||
}}
|
||||
/>
|
||||
</AppFullCalendar>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, xl: 3.5 }}>
|
||||
<Stack spacing={4}>
|
||||
<Card>
|
||||
<CardContent sx={{ p: 5 }}>
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography variant='overline' color='success.main'>
|
||||
برنامه انتخاب شده
|
||||
</Typography>
|
||||
<Typography variant='h5' sx={{ mt: 1 }}>
|
||||
{selectedEvent.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Chip
|
||||
label={getCalendarLabel(selectedEvent)}
|
||||
color={getCalendarChipColor(selectedEvent)}
|
||||
variant='tonal'
|
||||
sx={{ alignSelf: 'flex-start', fontWeight: 700 }}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
تاریخ
|
||||
</Typography>
|
||||
<Typography variant='body1' sx={{ mt: 0.5 }}>
|
||||
{selectedEventDate}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
بازه زمانی
|
||||
</Typography>
|
||||
<Typography variant='body1' sx={{ mt: 0.5 }}>
|
||||
{formatTimeRange(selectedEvent)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
توضیحات اجرایی
|
||||
</Typography>
|
||||
<Typography variant='body1' sx={{ mt: 1.5, lineHeight: 1.9 }}>
|
||||
{selectedEvent.extendedProps?.description as string}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent sx={{ p: 5 }}>
|
||||
<Typography variant='h6' sx={{ mb: 3 }}>
|
||||
برنامه های نزدیک
|
||||
</Typography>
|
||||
<Stack spacing={2.5}>
|
||||
{upcomingEvents.map(event => (
|
||||
<Box
|
||||
key={event.id}
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
cursor: 'pointer',
|
||||
border: theme => `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor:
|
||||
selectedEvent.id === event.id ? 'action.hover' : 'background.paper',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
transform: 'translateY(-2px)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack direction='row' justifyContent='space-between' spacing={2}>
|
||||
<Box>
|
||||
<Typography variant='subtitle2'>{event.title}</Typography>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
{dayFormatter.format(getEventDate(event))}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
size='small'
|
||||
label={getCalendarLabel(event)}
|
||||
color={getCalendarChipColor(event)}
|
||||
variant='tonal'
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default FarmerCalendarPage
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import Box from '@mui/material/Box'
|
||||
import Card from '@mui/material/Card'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
import CardHeader from '@mui/material/CardHeader'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
export type NotificationChannelKey = 'email' | 'sms' | 'push' | 'whatsapp'
|
||||
|
||||
export interface NotificationChannelSetting {
|
||||
key: NotificationChannelKey
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface NotificationSettingsCardProps {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
channels?: NotificationChannelSetting[]
|
||||
onChange?: (channels: NotificationChannelSetting[]) => void
|
||||
}
|
||||
|
||||
const defaultChannels: NotificationChannelSetting[] = [
|
||||
{
|
||||
key: 'email',
|
||||
label: 'اعلان ایمیل',
|
||||
description: 'هشدارهای مهم مزرعه به ایمیل مدیر ارسال شود.',
|
||||
icon: 'tabler-mail',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
key: 'sms',
|
||||
label: 'اعلان پیامکی',
|
||||
description: 'هشدارهای فوری مثل قطع آبیاری یا ریسک آفات با SMS ارسال شود.',
|
||||
icon: 'tabler-message',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
key: 'push',
|
||||
label: 'اعلان داخل پنل',
|
||||
description: 'نوتیفیکیشن ها در پنل و مرورگر نمایش داده شوند.',
|
||||
icon: 'tabler-bell-ringing',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
key: 'whatsapp',
|
||||
label: 'اعلان واتساپ',
|
||||
description: 'خلاصه هشدارهای روزانه برای گروه عملیات ارسال شود.',
|
||||
icon: 'tabler-brand-whatsapp',
|
||||
enabled: false
|
||||
}
|
||||
]
|
||||
|
||||
const NotificationSettingsCard = ({
|
||||
title = 'تنظیمات سیستم نوتیفیکیشن',
|
||||
subtitle = 'مشخص کن هشدارهای مزرعه از چه کانال هایی برای تیم ارسال شوند.',
|
||||
channels = defaultChannels,
|
||||
onChange
|
||||
}: NotificationSettingsCardProps) => {
|
||||
const [settings, setSettings] = useState<NotificationChannelSetting[]>(channels)
|
||||
|
||||
const enabledCount = useMemo(() => settings.filter(item => item.enabled).length, [settings])
|
||||
|
||||
const handleToggle = (key: NotificationChannelKey) => {
|
||||
const nextSettings = settings.map(item =>
|
||||
item.key === key ? { ...item, enabled: !item.enabled } : item
|
||||
)
|
||||
|
||||
setSettings(nextSettings)
|
||||
onChange?.(nextSettings)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(14,165,233,0.05) 0%, rgba(255,255,255,1) 42%)'
|
||||
}}
|
||||
>
|
||||
<CardHeader
|
||||
title={title}
|
||||
subheader={subtitle}
|
||||
action={
|
||||
<Chip
|
||||
size='small'
|
||||
color='success'
|
||||
variant='tonal'
|
||||
label={`${enabledCount} کانال فعال`}
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<CardContent sx={{ pt: 0 }}>
|
||||
<Stack spacing={2.5}>
|
||||
{settings.map((item, index) => (
|
||||
<Box key={item.key}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: 'flex-start', sm: 'center' }}
|
||||
justifyContent='space-between'
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
border: theme => `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: item.enabled ? 'action.hover' : 'background.paper'
|
||||
}}
|
||||
>
|
||||
<Stack direction='row' spacing={2} alignItems='flex-start'>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 2,
|
||||
color: item.enabled ? 'primary.main' : 'text.disabled',
|
||||
backgroundColor: item.enabled ? 'primary.lightOpacity' : 'action.selected'
|
||||
}}
|
||||
>
|
||||
<i className={item.icon} />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant='subtitle1'>{item.label}</Typography>
|
||||
<Typography variant='body2' color='text.secondary' sx={{ mt: 0.5 }}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<FormControlLabel
|
||||
sx={{ m: 0 }}
|
||||
control={
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
color='success'
|
||||
onChange={() => handleToggle(item.key)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant='body2' color={item.enabled ? 'success.main' : 'text.secondary'}>
|
||||
{item.enabled ? 'فعال' : 'غیرفعال'}
|
||||
</Typography>
|
||||
}
|
||||
labelPlacement='start'
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{index < settings.length - 1 ? <Divider sx={{ my: 0.5, opacity: 0 }} /> : null}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationSettingsCard
|
||||
@@ -1,192 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useFarmHub } from '@/hooks/useFarmHub'
|
||||
|
||||
// MUI Imports
|
||||
import Box from '@mui/material/Box'
|
||||
import Grid from '@mui/material/Grid2'
|
||||
import Card from '@mui/material/Card'
|
||||
import CardHeader from '@mui/material/CardHeader'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
// Third-party Imports
|
||||
import type { ApexOptions } from 'apexcharts'
|
||||
|
||||
// Component Imports
|
||||
import FarmWeatherCard from '@views/dashboards/farm/FarmWeatherCard'
|
||||
import { farmDashboardService } from '@/libs/api/services/farmDashboardService'
|
||||
|
||||
// Styled Component Imports
|
||||
const AppReactApexCharts = dynamic(() => import('@/libs/styles/AppReactApexCharts'))
|
||||
|
||||
const DEFAULT_WEATHER = {
|
||||
temperature: 24,
|
||||
condition: 'آفتابی',
|
||||
humidity: 45,
|
||||
windSpeed: 12,
|
||||
windUnit: 'km/h',
|
||||
unit: '°C',
|
||||
precipitation: 0
|
||||
}
|
||||
|
||||
const DEFAULT_FORECAST_SERIES = [
|
||||
{ name: 'دما', data: [18, 22, 26, 28, 25, 20, 18] },
|
||||
{ name: 'رطوبت', data: [55, 48, 42, 38, 45, 52, 58] }
|
||||
]
|
||||
|
||||
const FORECAST_CATEGORIES = ['امروز', 'فردا', 'شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه']
|
||||
|
||||
export default function CropZoningWeatherSection() {
|
||||
const t = useTranslations('cropZoning.weather')
|
||||
const { farmHub } = useFarmHub()
|
||||
const farmUuid = farmHub?.farm_uuid
|
||||
const [weatherData, setWeatherData] = useState<Record<string, unknown>>(DEFAULT_WEATHER)
|
||||
const [forecastSeries, setForecastSeries] = useState(DEFAULT_FORECAST_SERIES)
|
||||
|
||||
useEffect(() => {
|
||||
if (!farmUuid) {
|
||||
setWeatherData(DEFAULT_WEATHER)
|
||||
setForecastSeries(DEFAULT_FORECAST_SERIES)
|
||||
return
|
||||
}
|
||||
|
||||
farmDashboardService
|
||||
.getAllCards(farmUuid)
|
||||
.then(cards => {
|
||||
const w = cards?.farmWeatherCard
|
||||
if (w && typeof w === 'object') {
|
||||
setWeatherData({ ...DEFAULT_WEATHER, ...w })
|
||||
const chartData = w.chartData as { labels?: string[]; series?: number[][] } | undefined
|
||||
if (chartData?.series?.[0]) {
|
||||
setForecastSeries([{ name: 'دما', data: chartData.series[0] }])
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [farmUuid])
|
||||
|
||||
const forecastOptions: ApexOptions = {
|
||||
chart: {
|
||||
parentHeightOffset: 0,
|
||||
toolbar: { show: false },
|
||||
zoom: { enabled: false }
|
||||
},
|
||||
colors: ['var(--mui-palette-info-main)', 'var(--mui-palette-success-main)'],
|
||||
stroke: { width: 2, curve: 'smooth' },
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: { colors: 'var(--mui-palette-text-secondary)' }
|
||||
},
|
||||
dataLabels: { enabled: false },
|
||||
grid: {
|
||||
borderColor: 'var(--mui-palette-divider)',
|
||||
strokeDashArray: 4,
|
||||
xaxis: { lines: { show: false } },
|
||||
yaxis: { lines: { show: true } }
|
||||
},
|
||||
xaxis: {
|
||||
categories: FORECAST_CATEGORIES,
|
||||
labels: { style: { colors: 'var(--mui-palette-text-disabled)' } },
|
||||
axisBorder: { show: false },
|
||||
axisTicks: { show: false }
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: { colors: 'var(--mui-palette-text-disabled)' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const temp = (weatherData.temperature as number) ?? 24
|
||||
const humidity = (weatherData.humidity as number) ?? 45
|
||||
const windSpeed = (weatherData.windSpeed as number) ?? 12
|
||||
|
||||
const cardRowSx = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
minHeight: 200,
|
||||
'& > *': { flex: 1, minHeight: 0 }
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className='pis-0 pie-0'>
|
||||
<Typography variant='h6' className='mbe-4 font-semibold'>
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
{/* Row 1: Weather + 3 KPI cards — equal width (3 each on md+) */}
|
||||
<Grid
|
||||
size={12}
|
||||
container
|
||||
spacing={3}
|
||||
sx={{ display: 'flex', alignItems: 'stretch' }}
|
||||
>
|
||||
<Grid size={{ xs: 12, md: 3 }} sx={cardRowSx}>
|
||||
<FarmWeatherCard data={weatherData} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 4, md: 3 }} sx={cardRowSx}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<CardContent className='flex flex-1 flex-col items-center justify-center gap-2'>
|
||||
<i className='tabler-temperature text-3xl text-error' />
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
{t('temperature')}
|
||||
</Typography>
|
||||
<Typography variant='h5'>
|
||||
{temp}
|
||||
{(weatherData.unit as string) ?? '°C'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 4, md: 3 }} sx={cardRowSx}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<CardContent className='flex flex-1 flex-col items-center justify-center gap-2'>
|
||||
<i className='tabler-droplet text-3xl text-info' />
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
{t('humidity')}
|
||||
</Typography>
|
||||
<Typography variant='h5'>{humidity}%</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 4, md: 3 }} sx={cardRowSx}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<CardContent className='flex flex-1 flex-col items-center justify-center gap-2'>
|
||||
<i className='tabler-wind text-3xl text-success' />
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
{t('windSpeed')}
|
||||
</Typography>
|
||||
<Typography variant='h5'>
|
||||
{windSpeed} {(weatherData.windUnit as string) ?? 'km/h'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Row 2: Forecast chart — full width */}
|
||||
<Grid size={12} sx={cardRowSx}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 280 }}>
|
||||
<CardHeader
|
||||
title={t('forecastChart')}
|
||||
subheader={t('forecastSubheader')}
|
||||
/>
|
||||
<CardContent sx={{ flex: 1, minHeight: 0 }}>
|
||||
<AppReactApexCharts
|
||||
type='line'
|
||||
height={260}
|
||||
width='100%'
|
||||
series={forecastSeries}
|
||||
options={forecastOptions}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -6,18 +6,21 @@ import { useTranslations } from "next-intl";
|
||||
import { useFarmHub } from "@/hooks/useFarmHub";
|
||||
import Box from "@mui/material/Box";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Grid from "@mui/material/Grid2";
|
||||
import LinearProgress from "@mui/material/LinearProgress";
|
||||
import NDVIHealthCard from "@views/dashboards/farm/NDVIHealthCard";
|
||||
import SatelliteImageDownloadCard from "./SatelliteImageDownloadCard";
|
||||
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,
|
||||
} from "@/libs/api/services/cropZoningService";
|
||||
import { cropHealthService } from "@/libs/api/services/cropHealthService";
|
||||
import { CROP_COLORS, type CropType } from "./cropZoningTypes";
|
||||
import type { LayerType } from "./cropZoningTypes";
|
||||
import type { MapDrawGeoJSON } from "./CropZoningMap";
|
||||
@@ -55,6 +58,7 @@ export default function CropZoningWrapper() {
|
||||
const [activeLayer, setActiveLayer] = useState<LayerType>("crops");
|
||||
const [selectedZone, setSelectedZone] = useState<ZoneDetailData | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [ndviData, setNdviData] = useState<Record<string, unknown>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setIsClientReady(true);
|
||||
@@ -66,6 +70,18 @@ export default function CropZoningWrapper() {
|
||||
.catch(() => setProducts([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!farmUuid) {
|
||||
setNdviData({});
|
||||
return;
|
||||
}
|
||||
|
||||
cropHealthService
|
||||
.getSummary(farmUuid)
|
||||
.then(summary => setNdviData((summary.ndviHealthCard as Record<string, unknown>) ?? {}))
|
||||
.catch(() => setNdviData({}));
|
||||
}, [farmUuid]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -362,8 +378,20 @@ export default function CropZoningWrapper() {
|
||||
<ZoneLegend activeLayer={activeLayer} products={products} loading={false} />
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={6} alignItems="stretch">
|
||||
<Grid size={{ xs: 12, xl: 5 }} sx={{ display: "flex" }}>
|
||||
<Box sx={{ width: "100%", "& > *": { height: "100%" } }}>
|
||||
<NDVIHealthCard data={ndviData} />
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, xl: 7 }} sx={{ display: "flex" }}>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<SatelliteImageDownloadCard farmUuid={farmUuid} />
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<ZoneDetailPanel open={panelOpen} onClose={() => setPanelOpen(false)} zone={selectedZone} products={products} loading={false} />
|
||||
<CropZoningWeatherSection />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Card from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import CardHeader from "@mui/material/CardHeader";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
interface SatelliteImageDownloadCardProps {
|
||||
farmUuid?: string | null;
|
||||
}
|
||||
|
||||
const toInputDate = (date: Date) => {
|
||||
const offset = date.getTimezoneOffset();
|
||||
const localDate = new Date(date.getTime() - offset * 60_000);
|
||||
|
||||
return localDate.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const formatPersianDate = (value: string) => {
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const createSatellitePlaceholderSvg = (farmUuid: string, selectedDate: string) => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1400" height="900" viewBox="0 0 1400 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1400" y2="900" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1B4332"/>
|
||||
<stop offset="1" stop-color="#588157"/>
|
||||
</linearGradient>
|
||||
<pattern id="grid" width="120" height="120" patternUnits="userSpaceOnUse">
|
||||
<path d="M120 0H0V120" stroke="rgba(255,255,255,0.16)" stroke-width="2"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="1400" height="900" fill="url(#bg)"/>
|
||||
<rect width="1400" height="900" fill="url(#grid)"/>
|
||||
<rect x="110" y="120" width="1180" height="660" rx="42" fill="rgba(10, 25, 18, 0.38)" stroke="rgba(255,255,255,0.22)" stroke-width="4"/>
|
||||
<path d="M250 260C346 208 487 214 596 268C698 319 789 344 901 312C1004 281 1104 296 1180 354V664C1079 620 980 618 886 647C763 684 648 665 532 603C415 541 341 521 250 554V260Z" fill="#90BE6D" fill-opacity="0.72"/>
|
||||
<path d="M282 327C371 285 481 291 571 336C654 378 752 403 842 381C959 352 1045 356 1140 410" stroke="#D9ED92" stroke-width="18" stroke-linecap="round" stroke-dasharray="20 18"/>
|
||||
<path d="M308 614C420 568 503 573 600 631C705 694 816 709 932 669C1011 642 1084 641 1149 659" stroke="#081C15" stroke-opacity="0.52" stroke-width="26" stroke-linecap="round"/>
|
||||
<circle cx="1118" cy="232" r="78" fill="rgba(255,255,255,0.08)"/>
|
||||
<circle cx="1118" cy="232" r="54" fill="rgba(255,255,255,0.12)"/>
|
||||
<text x="170" y="190" fill="white" font-family="Arial, sans-serif" font-size="46" font-weight="700">Satellite Snapshot Request</text>
|
||||
<text x="170" y="244" fill="rgba(255,255,255,0.82)" font-family="Arial, sans-serif" font-size="28">Farm UUID: ${farmUuid}</text>
|
||||
<text x="170" y="286" fill="rgba(255,255,255,0.82)" font-family="Arial, sans-serif" font-size="28">Capture Date: ${selectedDate}</text>
|
||||
<text x="170" y="755" fill="rgba(255,255,255,0.78)" font-family="Arial, sans-serif" font-size="24">This placeholder download can be replaced with a real satellite API image endpoint later.</text>
|
||||
</svg>`;
|
||||
|
||||
export default function SatelliteImageDownloadCard({
|
||||
farmUuid,
|
||||
}: SatelliteImageDownloadCardProps) {
|
||||
const [selectedDate, setSelectedDate] = useState(toInputDate(new Date()));
|
||||
|
||||
const formattedDate = useMemo(
|
||||
() => formatPersianDate(selectedDate),
|
||||
[selectedDate],
|
||||
);
|
||||
|
||||
const handleDownload = () => {
|
||||
const safeFarmUuid = farmUuid || "no-farm-selected";
|
||||
const svg = createSatellitePlaceholderSvg(safeFarmUuid, selectedDate);
|
||||
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
||||
link.href = url;
|
||||
link.download = `satellite-${safeFarmUuid}-${selectedDate}.svg`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<CardHeader
|
||||
title="دانلود تصویر ماهواره ای"
|
||||
subheader="یک روز مشخص را انتخاب کن تا اسنپ شات زمین برای دانلود آماده شود."
|
||||
action={<Chip size="small" color="info" variant="tonal" label="Satellite" />}
|
||||
/>
|
||||
<CardContent
|
||||
sx={{
|
||||
pt: 0,
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: 180,
|
||||
borderRadius: 4,
|
||||
p: { xs: 3, md: 4 },
|
||||
color: "common.white",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(17,24,39,1) 0%, rgba(35,84,61,1) 52%, rgba(111,163,91,1) 100%)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
opacity: 0.15,
|
||||
backgroundImage:
|
||||
"linear-gradient(rgba(255,255,255,0.7) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.7) 1px, transparent 1px)",
|
||||
backgroundSize: "44px 44px",
|
||||
}}
|
||||
/>
|
||||
<Stack spacing={1.5} sx={{ position: "relative", zIndex: 1 }}>
|
||||
<Typography variant="overline" sx={{ opacity: 0.82 }}>
|
||||
Snapshot Preview
|
||||
</Typography>
|
||||
<Typography variant="h5">تصویر ثبت شده برای مزرعه انتخابی</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.86, maxWidth: 480 }}>
|
||||
تاریخ انتخابی به صورت یک فایل تصویری دانلود می شود تا تیم مزرعه بتواند
|
||||
وضعیت زمین را برای همان روز آرشیو یا بررسی کند.
|
||||
</Typography>
|
||||
<Chip
|
||||
label={formattedDate}
|
||||
color="success"
|
||||
variant="filled"
|
||||
sx={{ alignSelf: "flex-start", fontWeight: 700 }}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
border: theme => `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: "background.default",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2.5}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
تنظیمات درخواست تصویر
|
||||
</Typography>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
>
|
||||
<TextField
|
||||
label="تاریخ تصویر"
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={event => setSelectedDate(event.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
fullWidth
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
size="large"
|
||||
onClick={handleDownload}
|
||||
disabled={!farmUuid}
|
||||
startIcon={<i className="tabler-download" />}
|
||||
sx={{ minWidth: { xs: "100%", md: 220 }, py: 1.7 }}
|
||||
>
|
||||
دانلود عکس ماهواره ای
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={1.5}
|
||||
useFlexGap
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
label={`تاریخ انتخابی: ${formattedDate}`}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
color={farmUuid ? "primary" : "default"}
|
||||
variant="tonal"
|
||||
label={farmUuid ? "مزرعه انتخاب شده" : "ابتدا مزرعه را انتخاب کن"}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
فعلا خروجی به صورت فایل تصویری placeholder دانلود می شود؛ بعدا می توان این دکمه را
|
||||
به API واقعی تصویر ماهواره ای وصل کرد.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export { default as CropZoningWrapper } from './CropZoningWrapper'
|
||||
export { default as CropZoningWeatherSection } from './CropZoningWeatherSection'
|
||||
export { default as CropZoningMap } from './CropZoningMap'
|
||||
export { default as ZoneLegend } from './ZoneLegend'
|
||||
export { default as LayerControl } from './LayerControl'
|
||||
|
||||
Reference in New Issue
Block a user