This commit is contained in:
2026-04-02 23:44:24 +03:30
parent 9af6f7f36f
commit 76fd2a29f8
27 changed files with 632 additions and 294 deletions
+21 -14
View File
@@ -265,33 +265,36 @@
}
},
"sensorHub": {
"title": "مرکز سنسور",
"title": "مرکز مزرعه",
"cancel": "انصراف",
"selectSensor": "انتخاب سنسور",
"selectSensorDescription": "سنسور مورد نظر را انتخاب کنید یا سنسور جدید اضافه کنید",
"addSensor": "اضافه کردن سنسور",
"sensorData": "داده سنسور",
"selectSensor": "انتخاب مزرعه",
"selectSensorDescription": "مزرعه مورد نظر را انتخاب کنید یا مزرعه جدید اضافه کنید",
"addSensor": "افزودن مزرعه",
"sensorData": "داده مزرعه",
"back": "بازگشت",
"sensorName": "نام سنسور",
"sensorUuid": "شناسه سنسور (UUID)",
"placeholderName": "نام سنسور را وارد کنید",
"placeholderUuid": "شناسه سنسور را وارد کنید",
"sensorName": "نام مزرعه",
"sensorUuid": "شناسه مزرعه (UUID)",
"stationName": "نام ایستگاه یا سنسور اصلی",
"placeholderName": "نام مزرعه را وارد کنید",
"placeholderUuid": "شناسه مزرعه را وارد کنید",
"placeholderStationName": "نام ایستگاه اصلی را وارد کنید",
"plantType": "نوع گیاه",
"plantName": "اسم گیاه",
"saveSensor": "ذخیره سنسور",
"mapAreaDescription": "محدوده مزرعه را روی نقشه مشخص کنید تا در صورت نیاز zoning اولیه هم ساخته شود.",
"saveSensor": "ذخیره مزرعه",
"saving": "در حال ذخیره...",
"errorSave": "خطا در ذخیره سنسور",
"errorSave": "خطا در ذخیره مزرعه",
"columns": {
"name": "نام",
"lastUpdate": "آخرین بروزرسانی",
"uuid": "شناسه یکتا"
"uuid": "شناسه مزرعه"
},
"ariaClose": "بستن",
"errorLoad": "خطا در بارگذاری سنسورها"
"errorLoad": "خطا در بارگذاری مزارع"
},
"accountSettings": {
"account": "حساب کاربری",
"sensorHub": "مرکز سنسور",
"sensorHub": "مرکز مزرعه",
"firstName": "نام",
"lastName": "نام خانوادگی",
"email": "ایمیل",
@@ -540,6 +543,7 @@
"failed": "پردازش محدوده زمین ناموفق بود."
},
"errors": {
"noFarm": "ابتدا یک مزرعه انتخاب کنید.",
"areaLoadFailed": "بارگذاری محدوده زمین با خطا مواجه شد.",
"timeout": "دریافت نتیجه محدوده زمین بیش از حد طول کشید. دوباره تلاش کنید."
},
@@ -636,6 +640,7 @@
"generateCta": "تولید برنامه آبیاری",
"generating": "در حال تحلیل و تولید برنامه آبیاری...",
"errors": {
"noFarm": "ابتدا یک مزرعه انتخاب کنید.",
"generateFailed": "دریافت برنامه آبیاری با خطا مواجه شد.",
"timeout": "دریافت نتیجه بیش از حد طول کشید. دوباره تلاش کنید."
},
@@ -686,6 +691,7 @@
"generateCta": "تولید برنامه کوددهی",
"generating": "در حال تحلیل و تولید نسخه تغذیه‌ای...",
"errors": {
"noFarm": "ابتدا یک مزرعه انتخاب کنید.",
"generateFailed": "دریافت برنامه کوددهی با خطا مواجه شد.",
"timeout": "دریافت نتیجه بیش از حد طول کشید. دوباره تلاش کنید."
},
@@ -759,6 +765,7 @@
"whyThis": "چرا این توصیه؟"
},
"errors": {
"noFarm": "ابتدا یک مزرعه انتخاب کنید.",
"contextLoad": "بارگذاری زمینه مزرعه ناموفق بود.",
"chatSend": "ارسال پیام ناموفق بود. دوباره تلاش کنید.",
"conversationLoad": "بارگذاری لیست مکالمات ناموفق بود.",
+4 -5
View File
@@ -1,20 +1,19 @@
'use client'
// React Imports
import { useEffect, type ReactNode } from 'react'
import type { ReactNode } from 'react'
// Hook Imports
import { useSensorHub } from '@/hooks/useSensorHub'
import { Box } from '@mui/material'
import { useFarmHub } from '@/hooks/useFarmHub'
import SensorHubView from '@/views/sensorHub'
interface SensorHubProps {
children: ReactNode
}
export default function SensorHub({ children }: SensorHubProps) {
const { hasSensorHub } = useSensorHub()
const { hasFarmHub } = useFarmHub()
return <> {children}
{!hasSensorHub && <SensorHubView />}</>
{!hasFarmHub && <SensorHubView />}</>
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useCallback, useEffect, useState } from "react";
export const FARM_HUB_STORAGE_KEY = "farm_hub";
const LEGACY_SENSOR_HUB_STORAGE_KEY = "sensor_hub";
export interface FarmHubInfo {
farm_uuid: string;
id?: string;
name?: string;
is_active?: boolean;
[key: string]: unknown;
}
export interface UseFarmHubReturn {
farmHub: FarmHubInfo | null;
hasFarmHub: boolean;
setFarmHub: (data: FarmHubInfo | null) => void;
getFarmUuid: () => string | null;
}
const normalizeFarmHub = (raw: unknown): FarmHubInfo | null => {
if (!raw || typeof raw !== "object") {
return null;
}
const candidate = raw as Record<string, unknown>;
const farmUuid =
typeof candidate.farm_uuid === "string"
? candidate.farm_uuid
: typeof candidate.id === "string"
? candidate.id
: null;
if (!farmUuid) {
return null;
}
return {
...candidate,
farm_uuid: farmUuid,
id: typeof candidate.id === "string" ? candidate.id : farmUuid,
} as FarmHubInfo;
};
const parseFarmHub = (raw: string | null): FarmHubInfo | null => {
if (!raw) return null;
try {
return normalizeFarmHub(JSON.parse(raw));
} catch {
return null;
}
};
export const getStoredFarmHub = (): FarmHubInfo | null => {
if (typeof window === "undefined") return null;
return (
parseFarmHub(localStorage.getItem(FARM_HUB_STORAGE_KEY)) ??
parseFarmHub(localStorage.getItem(LEGACY_SENSOR_HUB_STORAGE_KEY))
);
};
export const getStoredFarmUuid = (): string | null =>
getStoredFarmHub()?.farm_uuid ?? null;
export const useFarmHub = (): UseFarmHubReturn => {
const [farmHub, setFarmHubState] = useState<FarmHubInfo | null>(null);
useEffect(() => {
setFarmHubState(getStoredFarmHub());
}, []);
const setFarmHub = useCallback((data: FarmHubInfo | null) => {
if (typeof window === "undefined") return;
if (data === null) {
localStorage.removeItem(FARM_HUB_STORAGE_KEY);
setFarmHubState(null);
return;
}
const normalized = normalizeFarmHub(data);
if (!normalized) {
return;
}
localStorage.setItem(FARM_HUB_STORAGE_KEY, JSON.stringify(normalized));
setFarmHubState(normalized);
}, []);
const getFarmUuid = useCallback(() => {
return farmHub?.farm_uuid ?? getStoredFarmUuid();
}, [farmHub]);
return {
farmHub,
hasFarmHub: farmHub !== null,
setFarmHub,
getFarmUuid,
};
};
+11 -69
View File
@@ -1,71 +1,13 @@
'use client'
"use client";
// React Imports
import { useState, useEffect, useCallback } from 'react'
export {
FARM_HUB_STORAGE_KEY as SENSOR_HUB_STORAGE_KEY,
getStoredFarmHub as getStoredSensorHub,
getStoredFarmUuid as getStoredSensorHubId,
useFarmHub as useSensorHub,
} from "./useFarmHub";
const SENSOR_HUB_STORAGE_KEY = 'sensor_hub'
export interface SensorHubInfo {
id: string
[key: string]: unknown
}
export interface UseSensorHubReturn {
/** Sensor hub data from localStorage */
sensorHub: SensorHubInfo | null
/** Whether sensor_hub exists in localStorage */
hasSensorHub: boolean
/** Save sensor hub to localStorage */
setSensorHub: (data: SensorHubInfo | null) => void
/** Get headers to attach to API requests (e.g. X-Sensor-Hub-Id) */
getSensorHubHeaders: () => Record<string, string>
}
const parseSensorHub = (raw: string | null): SensorHubInfo | null => {
if (!raw) return null
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && typeof parsed.id === 'string') {
return parsed as SensorHubInfo
}
} catch {
// ignore invalid JSON
}
return null
}
export const useSensorHub = (): UseSensorHubReturn => {
const [sensorHub, setSensorHubState] = useState<SensorHubInfo | null>(null)
useEffect(() => {
if (typeof window === 'undefined') return
const stored = localStorage.getItem(SENSOR_HUB_STORAGE_KEY)
setSensorHubState(parseSensorHub(stored))
}, [])
const setSensorHub = useCallback((data: SensorHubInfo | null) => {
if (typeof window === 'undefined') return
if (data === null) {
localStorage.removeItem(SENSOR_HUB_STORAGE_KEY)
setSensorHubState(null)
} else {
localStorage.setItem(SENSOR_HUB_STORAGE_KEY, JSON.stringify(data))
setSensorHubState(data)
}
}, [])
const getSensorHubHeaders = useCallback((): Record<string, string> => {
const hub = sensorHub ?? (typeof window !== 'undefined' ? parseSensorHub(localStorage.getItem(SENSOR_HUB_STORAGE_KEY)) : null)
if (!hub?.id) return {}
return {
'X-Sensor-Hub-Id': hub.id
}
}, [sensorHub])
return {
sensorHub,
hasSensorHub: sensorHub !== null,
setSensorHub,
getSensorHubHeaders
}
}
export type {
FarmHubInfo as SensorHubInfo,
UseFarmHubReturn as UseSensorHubReturn,
} from "./useFarmHub";
+1 -1
View File
@@ -33,7 +33,7 @@ export {
userManagementService,
} from "./services/userManagementService";
export * from "./services/rolesPermissionsService";
export * from "./services/sensorHubService";
export * from "./services/farmHubService";
export {
type FarmDashboardConfigResponse,
type FarmDashboardCardsResponse,
+15 -15
View File
@@ -217,7 +217,7 @@ function getAreaCacheUserKey(): string {
}
function getAreaCacheKey(
sensorUuid: string,
farmUuid: string,
page: number,
pageSize: number,
): string {
@@ -225,21 +225,21 @@ function getAreaCacheKey(
AREA_CACHE_KEY_PREFIX,
AREA_CACHE_VERSION,
getAreaCacheUserKey(),
sensorUuid,
farmUuid,
page,
pageSize,
].join(":");
}
function readCachedArea(
sensorUuid: string,
farmUuid: string,
page: number,
pageSize: number,
): CropZoningAreaResult | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(getAreaCacheKey(sensorUuid, page, pageSize));
const raw = localStorage.getItem(getAreaCacheKey(farmUuid, page, pageSize));
if (!raw) {
return null;
@@ -248,7 +248,7 @@ function readCachedArea(
const parsed = JSON.parse(raw) as CachedAreaEntry;
if (!parsed?.expiresAt || parsed.expiresAt < Date.now()) {
localStorage.removeItem(getAreaCacheKey(sensorUuid, page, pageSize));
localStorage.removeItem(getAreaCacheKey(farmUuid, page, pageSize));
return null;
}
@@ -259,7 +259,7 @@ function readCachedArea(
}
function writeCachedArea(
sensorUuid: string,
farmUuid: string,
page: number,
pageSize: number,
value: CropZoningAreaResult,
@@ -273,7 +273,7 @@ function writeCachedArea(
};
localStorage.setItem(
getAreaCacheKey(sensorUuid, page, pageSize),
getAreaCacheKey(farmUuid, page, pageSize),
JSON.stringify(payload),
);
} catch {
@@ -320,7 +320,7 @@ export const cropZoningService = {
},
getArea(
sensorUuid: string,
farmUuid: string,
options?: { page?: number; pageSize?: number; useCache?: boolean },
): Promise<CropZoningAreaResponse> {
const page = options?.page ?? 1;
@@ -328,11 +328,11 @@ export const cropZoningService = {
const useCache = options?.useCache ?? true;
if (useCache) {
const cached = readCachedArea(sensorUuid, page, pageSize);
const cached = readCachedArea(farmUuid, page, pageSize);
if (cached) {
logAreaRequest("cache-hit", {
sensorUuid,
farmUuid,
page,
pageSize,
pagination: cached.pagination ?? null,
@@ -343,7 +343,7 @@ export const cropZoningService = {
}
}
const params = new URLSearchParams({ sensor_uuid: sensorUuid });
const params = new URLSearchParams({ farm_uuid: farmUuid });
params.set("page", String(page));
params.set("page_size", String(pageSize));
@@ -351,7 +351,7 @@ export const cropZoningService = {
const endpoint = `${PREFIX}/area/?${params.toString()}`;
logAreaRequest("request", {
sensorUuid,
farmUuid,
page,
pageSize,
endpoint,
@@ -362,7 +362,7 @@ export const cropZoningService = {
).then((response) => {
if ("task_id" in response) {
logAreaRequest("response", {
sensorUuid,
farmUuid,
page,
pageSize,
taskId: response.task_id,
@@ -375,7 +375,7 @@ export const cropZoningService = {
const taskStatus = normalized.task?.status?.toLowerCase();
logAreaRequest("response", {
sensorUuid,
farmUuid,
page,
pageSize,
taskStatus: normalized.task?.status ?? null,
@@ -391,7 +391,7 @@ export const cropZoningService = {
taskStatus !== "failure" &&
taskStatus !== "failed"
) {
writeCachedArea(sensorUuid, page, pageSize, normalized);
writeCachedArea(farmUuid, page, pageSize, normalized);
}
return normalized;
+42 -10
View File
@@ -4,6 +4,7 @@ import type { ConversationSummary, FarmContext } from '@views/dashboards/farm/fa
const PREFIX = '/api/farm-ai-assistant'
export interface FarmContextResponse {
farm_uuid?: string
soilType: string
waterEC: string
selectedCrop: string
@@ -24,6 +25,7 @@ export interface ChatSection {
}
export interface ChatPayload {
farm_uuid: string
content?: string
images?: string[]
conversation_id?: string
@@ -37,12 +39,14 @@ export interface ChatTaskInitResponse {
status_url?: string
conversation_id: string
message_id: string
farm_uuid?: string
}
export interface ChatTaskStatusResponse {
task_id: string
status: 'PENDING' | 'STARTED' | 'SUCCESS' | 'FAILURE'
conversation_id: string
farm_uuid?: string
progress?: {
message?: string
}
@@ -53,6 +57,7 @@ export interface ChatTaskStatusResponse {
export interface ChatMessageResponse {
message_id: string
conversation_id: string
farm_uuid?: string
role: 'user' | 'assistant'
content: string
sections: ChatSection[]
@@ -62,16 +67,19 @@ export interface ChatMessageResponse {
export interface ConversationMessagesResponse {
conversation_id: string
farm_uuid?: string
messages: ChatMessageResponse[]
}
export interface CreateConversationPayload {
farm_uuid: string
title?: string
farm_context?: Partial<FarmContext>
}
export interface CreateConversationResponse {
id: string
farm_uuid?: string
message_count: number
title?: string
updated_at?: string
@@ -87,33 +95,57 @@ function unwrap<T>(res: ApiResponse<T>): T {
}
export const farmAiAssistantService = {
getContext(): Promise<FarmContextResponse> {
return apiClient.get<ApiResponse<FarmContextResponse>>(`${PREFIX}/context/`).then(unwrap)
getContext(farmUuid: string): Promise<FarmContextResponse> {
return apiClient
.get<ApiResponse<FarmContextResponse>>(
`${PREFIX}/context/?farm_uuid=${encodeURIComponent(farmUuid)}`,
)
.then(unwrap)
},
createChatTask(payload: ChatPayload): Promise<ChatTaskInitResponse> {
return apiClient.post<ApiResponse<ChatTaskInitResponse>>(`${PREFIX}/chat/task/`, payload).then(unwrap)
},
getChatTaskStatus(taskId: string): Promise<ChatTaskStatusResponse> {
return apiClient.get<ApiResponse<ChatTaskStatusResponse>>(`${PREFIX}/chat/task/${taskId}/status/`).then(unwrap)
getChatTaskStatus(taskId: string, farmUuid: string): Promise<ChatTaskStatusResponse> {
return apiClient
.get<ApiResponse<ChatTaskStatusResponse>>(
`${PREFIX}/chat/task/${taskId}/status/?farm_uuid=${encodeURIComponent(farmUuid)}`,
)
.then(unwrap)
},
getConversations(): Promise<ConversationSummary[]> {
return apiClient.get<ApiResponse<ConversationSummary[]>>(`${PREFIX}/chats/`).then(unwrap)
getConversations(farmUuid: string): Promise<ConversationSummary[]> {
return apiClient
.get<ApiResponse<ConversationSummary[]>>(
`${PREFIX}/chats/?farm_uuid=${encodeURIComponent(farmUuid)}`,
)
.then(unwrap)
},
createConversation(payload?: CreateConversationPayload): Promise<CreateConversationResponse> {
return apiClient.post<ApiResponse<CreateConversationResponse>>(`${PREFIX}/chats/`, payload).then(unwrap)
},
deleteConversation(conversationId: string): Promise<{ conversation_id: string }> {
return apiClient.delete<ApiResponse<{ conversation_id: string }>>(`${PREFIX}/chats/${conversationId}/`).then(unwrap)
deleteConversation(
conversationId: string,
farmUuid: string,
): Promise<{ conversation_id: string; farm_uuid?: string }> {
return apiClient
.delete<ApiResponse<{ conversation_id: string; farm_uuid?: string }>>(
`${PREFIX}/chats/${conversationId}/?farm_uuid=${encodeURIComponent(farmUuid)}`,
)
.then(unwrap)
},
getConversationMessages(conversationId: string): Promise<ConversationMessagesResponse> {
getConversationMessages(
conversationId: string,
farmUuid: string,
): Promise<ConversationMessagesResponse> {
return apiClient
.get<ApiResponse<ConversationMessagesResponse>>(`${PREFIX}/chats/${conversationId}/messages/`)
.get<ApiResponse<ConversationMessagesResponse>>(
`${PREFIX}/chats/${conversationId}/messages/?farm_uuid=${encodeURIComponent(farmUuid)}`,
)
.then(unwrap)
}
}
+31 -16
View File
@@ -23,6 +23,7 @@ export interface ApiResponse<T> {
}
export interface FarmDashboardConfigResponse {
farm_uuid?: string;
disabled_card_ids: string[];
row_order: string[];
enable_drag_reorder?: boolean;
@@ -48,7 +49,7 @@ export interface FarmDashboardCardsResponse {
}
interface FarmDashboardCardsTaskResult {
sensor_id?: string;
farm_uuid?: string;
all_cards?: FarmDashboardCardsResponse;
}
@@ -58,7 +59,11 @@ interface FarmDashboardCardsTaskData {
result?: FarmDashboardCardsTaskResult;
}
const STORAGE_KEY = "farm_dashboard_config";
const STORAGE_KEY_PREFIX = "farm_dashboard_config";
function getStorageKey(farmUuid: string): string {
return `${STORAGE_KEY_PREFIX}:${farmUuid}`;
}
function isCardId(value: string): value is CardId {
return (CARD_IDS as readonly string[]).includes(value);
@@ -149,34 +154,38 @@ function toApiRequest(
/**
* localStorage fallback when backend is not ready
*/
function getLocalConfig(): FarmDashboardConfig | null {
function getLocalConfig(farmUuid: string): FarmDashboardConfig | null {
if (typeof window === "undefined") return null;
try {
const stored = localStorage.getItem(STORAGE_KEY);
const stored = localStorage.getItem(getStorageKey(farmUuid));
return stored ? (JSON.parse(stored) as FarmDashboardConfig) : null;
} catch {
return null;
}
}
function setLocalConfig(config: FarmDashboardConfig): void {
function setLocalConfig(farmUuid: string, config: FarmDashboardConfig): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
localStorage.setItem(getStorageKey(farmUuid), JSON.stringify(config));
} catch (e) {
console.error("Failed to save farm dashboard config to localStorage", e);
}
}
function buildFarmQuery(farmUuid: string): string {
return `farm_uuid=${encodeURIComponent(farmUuid)}`;
}
export const farmDashboardService = {
/**
* Get farm dashboard config for current user
* Get farm dashboard config for the selected farm
*/
async getConfig(): Promise<FarmDashboardConfig> {
async getConfig(farmUuid: string): Promise<FarmDashboardConfig> {
try {
const response = await apiClient.get<
ApiResponse<FarmDashboardConfigResponse> | FarmDashboardConfigResponse
>("/api/farm-dashboard-config/");
>(`/api/farm-dashboard-config/?${buildFarmQuery(farmUuid)}`);
const raw = response && "data" in response ? response.data : response;
if (
raw &&
@@ -187,7 +196,7 @@ export const farmDashboardService = {
}
throw new Error("Invalid response");
} catch {
const local = getLocalConfig();
const local = getLocalConfig(farmUuid);
if (local) return local;
return { disabledCardIds: [], rowOrder: [], enableDragReorder: true };
}
@@ -197,12 +206,16 @@ export const farmDashboardService = {
* Update farm dashboard config
*/
async updateConfig(
farmUuid: string,
data: Partial<FarmDashboardConfig>,
): Promise<FarmDashboardConfig> {
try {
const response = await apiClient.patch<
ApiResponse<FarmDashboardConfigResponse> | FarmDashboardConfigResponse
>("/api/farm-dashboard-config/", toApiRequest(data));
>("/api/farm-dashboard-config/", {
farm_uuid: farmUuid,
...toApiRequest(data),
});
const raw = response && "data" in response ? response.data : response;
if (
raw &&
@@ -210,12 +223,12 @@ export const farmDashboardService = {
("disabled_card_ids" in raw || "row_order" in raw)
) {
const config = fromApiResponse(raw as FarmDashboardConfigResponse);
setLocalConfig(config);
setLocalConfig(farmUuid, config);
return config;
}
throw new Error("Update failed");
} catch (err) {
const local = getLocalConfig();
const local = getLocalConfig(farmUuid);
if (local) {
const merged: FarmDashboardConfig = {
disabledCardIds: data.disabledCardIds ?? local.disabledCardIds,
@@ -223,7 +236,7 @@ export const farmDashboardService = {
enableDragReorder:
data.enableDragReorder ?? local.enableDragReorder ?? true,
};
setLocalConfig(merged);
setLocalConfig(farmUuid, merged);
return merged;
}
throw err;
@@ -234,7 +247,9 @@ export const farmDashboardService = {
* Get all dashboard card data from API
* Response: { code: 200, msg: "OK", data: { farmOverviewKpis, farmWeatherCard, ... } }
*/
async getAllCards(): Promise<
async getAllCards(
farmUuid: string,
): Promise<
Partial<Record<CardId, Record<string, unknown>>>
> {
try {
@@ -243,7 +258,7 @@ export const farmDashboardService = {
| ApiResponse<FarmDashboardCardsTaskData>
| FarmDashboardCardsResponse
| FarmDashboardCardsTaskData
>("/api/farm-dashboard/");
>(`/api/farm-dashboard/?${buildFarmQuery(farmUuid)}`);
return extractCardsPayload(response);
} catch {
return {};
+115
View File
@@ -0,0 +1,115 @@
import { apiClient } from "../client";
export interface FarmTypeInfo {
uuid: string;
name: string;
description?: string;
metadata?: Record<string, unknown>;
}
export interface FarmProductInfo {
uuid: string;
name: string;
description?: string;
metadata?: Record<string, unknown>;
}
export interface FarmSensorInfo {
uuid?: string;
name: string;
sensor_type?: string;
is_active?: boolean;
specifications?: Record<string, unknown>;
power_source?: Record<string, unknown>;
customization?: Record<string, unknown>;
last_updated?: string;
}
export interface Farm {
farm_uuid: string;
name: string;
is_active?: boolean;
customization?: Record<string, unknown>;
farm_type?: FarmTypeInfo | null;
products?: FarmProductInfo[];
sensors?: FarmSensorInfo[];
zoning?: unknown;
last_updated?: string;
[key: string]: unknown;
}
export interface ListFarmsResponse {
status?: string;
data?: Farm | Farm[];
}
export interface CreateFarmPayload {
name: string;
farm_type_uuid?: string;
product_uuids?: string[];
customization?: Record<string, unknown>;
sensors?: FarmSensorInfo[];
area_geojson?: Record<string, unknown>;
area_m2?: number;
}
type ApiEnvelope<T> = {
data?: T;
};
const PREFIX = "/api/farm-hub";
function unwrapFarmCollection(
response: Farm[] | ListFarmsResponse | Farm | ApiEnvelope<Farm | Farm[]>,
): Farm[] {
const payload =
response && typeof response === "object" && "data" in response
? response.data
: response;
if (Array.isArray(payload)) {
return payload;
}
if (payload && typeof payload === "object") {
return [payload as Farm];
}
return [];
}
function unwrapSingle<T>(response: T | ApiEnvelope<T>): T {
const payload =
response && typeof response === "object" && "data" in response
? response.data
: response;
return payload as T;
}
export const farmHubService = {
async createFarm(payload: CreateFarmPayload): Promise<Farm> {
const response = await apiClient.post<Farm | ApiEnvelope<Farm>>(
`${PREFIX}/`,
payload,
);
return unwrapSingle(response);
},
async listFarms(): Promise<Farm[]> {
const response = await apiClient.get<
Farm[] | ListFarmsResponse | Farm | ApiEnvelope<Farm | Farm[]>
>(`${PREFIX}/`);
return unwrapFarmCollection(response);
},
async activateFarm(farmUuid: string): Promise<void> {
await apiClient.post(`${PREFIX}/active/`, { farm_uuid: farmUuid });
},
async deactivateFarm(farmUuid: string): Promise<void> {
await apiClient.post(`${PREFIX}/deactive/`, { farm_uuid: farmUuid });
},
};
@@ -30,6 +30,7 @@ export interface CropOption {
}
export interface FertilizationConfigResponse {
farm_uuid?: string;
farmData: FarmData;
growthStages: GrowthStage[];
cropOptions: CropOption[];
@@ -44,6 +45,7 @@ export interface FertilizationPlan {
}
export interface FertilizationRecommendPayload {
farm_uuid: string;
crop_id?: string;
growth_stage?: string;
farm_data?: Partial<FarmData>;
@@ -92,10 +94,10 @@ function normalizeRecommendationResult(
}
export const fertilizationRecommendationService = {
getConfig(): Promise<FertilizationConfigResponse> {
getConfig(farmUuid: string): Promise<FertilizationConfigResponse> {
return unwrap(
apiClient.get<ApiResponse<FertilizationConfigResponse>>(
`${PREFIX}/config/`,
`${PREFIX}/config/?farm_uuid=${encodeURIComponent(farmUuid)}`,
),
);
},
@@ -117,6 +119,7 @@ export const fertilizationRecommendationService = {
getRecommendStatus(
taskId: string,
farmUuid: string,
): Promise<
RecommendationTaskStatusResponse<FertilizationRecommendationResult>
> {
@@ -125,7 +128,9 @@ export const fertilizationRecommendationService = {
ApiResponse<
RecommendationTaskStatusResponse<FertilizationRecommendationResult>
>
>(`${PREFIX}/recommend/status/${taskId}/`),
>(
`${PREFIX}/recommend/status/${taskId}/?farm_uuid=${encodeURIComponent(farmUuid)}`,
),
).then((response) => ({
...response,
status: normalizeRecommendationTaskStatus(response.status),
@@ -25,6 +25,7 @@ export interface CropOption {
}
export interface IrrigationConfigResponse {
farm_uuid?: string;
farmInfo: FarmInfo;
cropOptions: CropOption[];
}
@@ -38,6 +39,7 @@ export interface IrrigationPlan {
}
export interface IrrigationRecommendPayload {
farm_uuid: string;
crop_id?: string;
farm_data?: Partial<FarmInfo>;
soilType?: string;
@@ -108,9 +110,11 @@ function normalizeRecommendationResult(
}
export const irrigationRecommendationService = {
getConfig(): Promise<IrrigationConfigResponse> {
getConfig(farmUuid: string): Promise<IrrigationConfigResponse> {
return unwrap(
apiClient.get<ApiResponse<IrrigationConfigResponse>>(`${PREFIX}/config/`),
apiClient.get<ApiResponse<IrrigationConfigResponse>>(
`${PREFIX}/config/?farm_uuid=${encodeURIComponent(farmUuid)}`,
),
);
},
@@ -131,13 +135,16 @@ export const irrigationRecommendationService = {
getRecommendStatus(
taskId: string,
farmUuid: string,
): Promise<RecommendationTaskStatusResponse<IrrigationRecommendationResult>> {
return unwrap(
apiClient.get<
ApiResponse<
RecommendationTaskStatusResponse<IrrigationRecommendationResult>
>
>(`${PREFIX}/recommend/status/${taskId}/`),
>(
`${PREFIX}/recommend/status/${taskId}/?farm_uuid=${encodeURIComponent(farmUuid)}`,
),
).then((response) => ({
...response,
status: normalizeRecommendationTaskStatus(response.status),
@@ -7,6 +7,7 @@ export type RecommendationTaskStatus =
export interface RecommendationTaskInitResponse {
task_id: string;
status: RecommendationTaskStatus;
farm_uuid?: string;
}
export interface RecommendationTaskProgress {
@@ -16,6 +17,7 @@ export interface RecommendationTaskProgress {
export interface RecommendationTaskStatusResponse<T> {
task_id: string;
status: RecommendationTaskStatus;
farm_uuid?: string;
progress?: RecommendationTaskProgress;
result?: T;
error?: string;
+19 -48
View File
@@ -1,54 +1,25 @@
/**
* Sensor Hub Service
* Handles sensor hub API calls
*/
import {
type CreateFarmPayload,
type Farm,
farmHubService,
} from "./farmHubService";
import { apiClient } from '../client'
export interface Sensor {
name: string
uuid_sensor: string
last_updated: string
[key: string]: unknown
}
export interface ListSensorsResponse {
status?: string
data: Sensor | Sensor[]
}
export interface AddSensorPayload {
name: string
uuid_sensor: string
area_geojson?: Record<string, unknown>
area_m2?: number
}
export type Sensor = Farm;
export type ListSensorsResponse = Farm[];
export type AddSensorPayload = CreateFarmPayload;
export const sensorHubService = {
/**
* Add a new sensor
*/
async addSensor(payload: AddSensorPayload): Promise<Sensor> {
const response = await apiClient.post<{ data?: Sensor }>('/api/sensor-hub/', payload)
return (response?.data as Sensor) ?? (payload as unknown as Sensor)
addSensor(payload: AddSensorPayload): Promise<Sensor> {
return farmHubService.createFarm(payload);
},
/**
* Get list of sensors
*/
async listSensors(): Promise<Sensor[]> {
const response = await apiClient.get<ListSensorsResponse>('/api/sensor-hub/')
const data = response?.data
listSensors(): Promise<Sensor[]> {
return farmHubService.listFarms();
},
};
if (Array.isArray(data)) {
return data
}
if (data && typeof data === 'object') {
return [data as Sensor]
}
return []
}
}
export {
type CreateFarmPayload,
type Farm,
farmHubService,
} from "./farmHubService";
@@ -4,6 +4,7 @@
import type { RefObject } from "react";
import { useEffect, useMemo, useState, useCallback, useContext } from "react";
import { useTranslations } from "next-intl";
import { useFarmHub } from "@/hooks/useFarmHub";
// Context Imports
import NavbarSlotContext from "@/contexts/navbarSlotContext";
@@ -97,6 +98,8 @@ function areStringArraysEqual(left: string[], right: string[]): boolean {
const FarmDashboardWrapper = () => {
const t = useTranslations("farmDashboard");
const { farmHub } = useFarmHub();
const farmUuid = farmHub?.farm_uuid;
const { setSlotContent } = useContext(NavbarSlotContext);
const [config, setConfig] = useState<FarmDashboardConfig>(
DEFAULT_FARM_DASHBOARD_CONFIG,
@@ -188,9 +191,18 @@ const FarmDashboardWrapper = () => {
useEffect(() => {
if (!farmUuid) {
setConfig(DEFAULT_FARM_DASHBOARD_CONFIG);
setCardsData({});
setLoading(false);
return;
}
setLoading(true);
Promise.all([
farmDashboardService.getConfig(),
farmDashboardService.getAllCards(),
farmDashboardService.getConfig(farmUuid),
farmDashboardService.getAllCards(farmUuid),
])
.then(([configData, cards]) => {
const validRowOrder = (configData.rowOrder ?? []).filter(
@@ -206,7 +218,7 @@ const FarmDashboardWrapper = () => {
})
.catch(() => setConfig(DEFAULT_FARM_DASHBOARD_CONFIG))
.finally(() => setLoading(false));
}, []);
}, [farmUuid]);
useEffect(() => {
if (areStringArraysEqual(orderedRows, visibleRowOrder)) return;
@@ -214,6 +226,7 @@ const FarmDashboardWrapper = () => {
}, [orderedRows, setOrderedRows, visibleRowOrder]);
useEffect(() => {
if (!farmUuid) return;
if (loading) return;
if (areStringArraysEqual(orderedRows, visibleRowOrder)) return;
const newRowOrder = mergeRowOrderAfterDrag(
@@ -225,35 +238,37 @@ const FarmDashboardWrapper = () => {
setConfig((prev) => ({ ...prev, rowOrder: newRowOrder }));
setSaving(true);
farmDashboardService
.updateConfig({ rowOrder: newRowOrder })
.updateConfig(farmUuid, { rowOrder: newRowOrder })
.then((updated) => setConfig(updated))
.catch(() => {})
.finally(() => setSaving(false));
}, [config.rowOrder, loading, orderedRows, visibleRowOrder]);
}, [config.rowOrder, farmUuid, loading, orderedRows, visibleRowOrder]);
const handleToggleDragReorder = useCallback((enabled: boolean) => {
if (!farmUuid) return;
setConfig((prev) => ({ ...prev, enableDragReorder: enabled }));
setSaving(true);
farmDashboardService
.updateConfig({ enableDragReorder: enabled })
.updateConfig(farmUuid, { enableDragReorder: enabled })
.then((updated) => setConfig(updated))
.finally(() => setSaving(false));
}, []);
}, [farmUuid]);
const handleToggleCard = useCallback(
(cardId: CardId, disabled: boolean) => {
if (!farmUuid) return;
const next = disabled
? [...config.disabledCardIds, cardId]
: config.disabledCardIds.filter((id) => id !== cardId);
setConfig((prev) => ({ ...prev, disabledCardIds: next }));
setSaving(true);
farmDashboardService
.updateConfig({ disabledCardIds: next })
.updateConfig(farmUuid, { disabledCardIds: next })
.then((updated) => setConfig(updated))
.catch(() => setConfig((prev) => ({ ...prev, disabledCardIds: next })))
.finally(() => setSaving(false));
},
[config.disabledCardIds],
[config.disabledCardIds, farmUuid],
);
@@ -2,6 +2,7 @@
// React Imports
import { useEffect, useState } from 'react'
import { useFarmHub } from '@/hooks/useFarmHub'
// MUI Imports
import Grid from '@mui/material/Grid2'
@@ -42,16 +43,25 @@ const CARD_COMPONENTS: Partial<Record<CardId, React.ComponentType<{ data?: Recor
}
const SoilDataDashboardWrapper = () => {
const { farmHub } = useFarmHub()
const farmUuid = farmHub?.farm_uuid
const [cardsData, setCardsData] = useState<Partial<Record<CardId, Record<string, unknown>>>>({})
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!farmUuid) {
setCardsData({})
setLoading(false)
return
}
setLoading(true)
farmDashboardService
.getAllCards()
.getAllCards(farmUuid)
.then(cards => setCardsData(cards ?? {}))
.catch(() => setCardsData({}))
.finally(() => setLoading(false))
}, [])
}, [farmUuid])
if (loading) {
return (
@@ -2,6 +2,7 @@
// React Imports
import { useEffect, useState } from 'react'
import { useFarmHub } from '@/hooks/useFarmHub'
// MUI Imports
import Grid from '@mui/material/Grid2'
@@ -39,16 +40,25 @@ const CARD_COMPONENTS: Partial<Record<CardId, React.ComponentType<{ data?: Recor
}
const WaterDataDashboardWrapper = () => {
const { farmHub } = useFarmHub()
const farmUuid = farmHub?.farm_uuid
const [cardsData, setCardsData] = useState<Partial<Record<CardId, Record<string, unknown>>>>({})
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!farmUuid) {
setCardsData({})
setLoading(false)
return
}
setLoading(true)
farmDashboardService
.getAllCards()
.getAllCards(farmUuid)
.then(cards => setCardsData(cards ?? {}))
.catch(() => setCardsData({}))
.finally(() => setLoading(false))
}, [])
}, [farmUuid])
if (loading) {
return (
@@ -3,6 +3,7 @@
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'
@@ -41,12 +42,20 @@ 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()
.getAllCards(farmUuid)
.then(cards => {
const w = cards?.farmWeatherCard
if (w && typeof w === 'object') {
@@ -58,7 +67,7 @@ export default function CropZoningWeatherSection() {
}
})
.catch(() => {})
}, [])
}, [farmUuid])
const forecastOptions: ApexOptions = {
chart: {
@@ -3,7 +3,7 @@
import { useState, useCallback, useEffect, useMemo } from "react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import { useSensorHub } from "@/hooks/useSensorHub";
import { useFarmHub } from "@/hooks/useFarmHub";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import LinearProgress from "@mui/material/LinearProgress";
@@ -43,7 +43,8 @@ const mergeZones = (pages: ZoneInitialData[][]) =>
export default function CropZoningWrapper() {
const t = useTranslations("cropZoning");
const { sensorHub } = useSensorHub();
const { farmHub } = useFarmHub();
const farmUuid = farmHub?.farm_uuid;
const [areaGeoJson, setAreaGeoJson] = useState<MapDrawGeoJSON>(null);
const [isClientReady, setIsClientReady] = useState(false);
const [loading, setLoading] = useState(true);
@@ -71,8 +72,10 @@ export default function CropZoningWrapper() {
const loadArea = async () => {
if (!isClientReady) return;
if (!sensorHub?.id) {
setError(t("errors.noSensor"));
if (!farmUuid) {
setAreaGeoJson(null);
setZonesData(null);
setError(t("errors.noFarm"));
setLoading(false);
return;
}
@@ -92,7 +95,7 @@ export default function CropZoningWrapper() {
completedTaskMessage?: string,
) => {
console.log("[crop-zoning][wrapper][first-page]", {
sensorUuid: sensorHub.id,
farmUuid,
pagination: firstResponse.pagination ?? null,
zonesCount: firstResponse.zones?.length ?? 0,
taskStatus: firstResponse.task?.status ?? null,
@@ -115,13 +118,13 @@ export default function CropZoningWrapper() {
if (cancelled) break;
console.log("[crop-zoning][wrapper][fetch-page]", {
sensorUuid: sensorHub.id,
farmUuid,
page,
pageSize,
totalPages,
});
const pageRes = await cropZoningService.getArea(sensorHub.id, {
const pageRes = await cropZoningService.getArea(farmUuid, {
page,
pageSize,
});
@@ -131,7 +134,7 @@ export default function CropZoningWrapper() {
}
console.log("[crop-zoning][wrapper][page-response]", {
sensorUuid: sensorHub.id,
farmUuid,
page,
pagination: pageRes.pagination ?? null,
zonesCount: pageRes.zones?.length ?? 0,
@@ -163,12 +166,12 @@ export default function CropZoningWrapper() {
while (!cancelled && polls < MAX_POLLS) {
console.log("[crop-zoning][wrapper][poll]", {
sensorUuid: sensorHub.id,
farmUuid,
pollAttempt: polls + 1,
pageSize: ZONES_PAGE_SIZE,
});
const res = await cropZoningService.getArea(sensorHub.id, {
const res = await cropZoningService.getArea(farmUuid, {
page: 1,
pageSize: ZONES_PAGE_SIZE,
});
@@ -179,7 +182,7 @@ export default function CropZoningWrapper() {
const taskStatus = getNormalizedTaskStatus(task?.status);
console.log("[crop-zoning][wrapper][poll-response]", {
sensorUuid: sensorHub.id,
farmUuid,
pollAttempt: polls + 1,
taskStatus: task?.status ?? null,
pagination: res.pagination ?? null,
@@ -234,7 +237,7 @@ export default function CropZoningWrapper() {
loadArea();
return () => { cancelled = true; };
}, [isClientReady, sensorHub, t]);
}, [farmUuid, isClientReady, t]);
const mapZonesData = useMemo(() => {
if (activeLayer === "crops" && zonesData) {
@@ -17,6 +17,7 @@ import classnames from 'classnames'
// Util Imports
import { commonLayoutClasses } from '@layouts/utils/layoutClasses'
import { farmAiAssistantService } from '@/libs/api/services/farmAiAssistantService'
import { useFarmHub } from '@/hooks/useFarmHub'
import type { ConversationSummary, FarmContext, FarmAIMessage, AIResponseSection } from './farmAiAssistantTypes'
import ChatSidebar from './ChatSidebar'
@@ -45,6 +46,8 @@ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
export default function FarmAiAssistantChat() {
const t = useTranslations('farmAiAssistant')
const theme = useTheme()
const { farmHub } = useFarmHub()
const farmUuid = farmHub?.farm_uuid
const [messages, setMessages] = useState<FarmAIMessage[]>([])
const [inputValue, setInputValue] = useState('')
const [isContextExpanded, setIsContextExpanded] = useState(true)
@@ -78,9 +81,14 @@ export default function FarmAiAssistantChat() {
})
const loadConversations = async () => {
if (!farmUuid) {
setConversations([])
return
}
setConversationLoading(true)
try {
const data = await farmAiAssistantService.getConversations()
const data = await farmAiAssistantService.getConversations(farmUuid)
setConversations(data)
} catch {
toast.error(t('errors.conversationLoad'))
@@ -97,8 +105,21 @@ export default function FarmAiAssistantChat() {
// Fetch farm context on mount
useEffect(() => {
let cancelled = false
setConversationId(null)
setMessages([])
if (!farmUuid) {
setContextLoading(false)
setConversations([])
return () => {
cancelled = true
}
}
setContextLoading(true)
farmAiAssistantService
.getContext()
.getContext(farmUuid)
.then(data => {
if (!cancelled) {
setFarmContext({
@@ -121,11 +142,11 @@ export default function FarmAiAssistantChat() {
return () => {
cancelled = true
}
}, [t])
}, [farmUuid, t])
useEffect(() => {
loadConversations()
}, [])
}, [farmUuid])
useEffect(() => {
if (sidebarOpen) {
@@ -150,6 +171,10 @@ export default function FarmAiAssistantChat() {
const handleSend = async (text?: string) => {
const content = (text || inputValue).trim()
if (!content) return
if (!farmUuid) {
toast.error(t('errors.noFarm'))
return
}
setInputValue('')
setSelectedChip(null)
@@ -165,6 +190,7 @@ export default function FarmAiAssistantChat() {
try {
const task = await farmAiAssistantService.createChatTask({
farm_uuid: farmUuid,
content,
title: !conversationId ? content.slice(0, 60) : undefined,
farm_context: farmContext,
@@ -176,7 +202,7 @@ export default function FarmAiAssistantChat() {
}
let attempts = 0
let taskStatus = await farmAiAssistantService.getChatTaskStatus(task.task_id)
let taskStatus = await farmAiAssistantService.getChatTaskStatus(task.task_id, farmUuid)
while (taskStatus.status === 'PENDING' || taskStatus.status === 'STARTED') {
attempts += 1
@@ -186,7 +212,7 @@ export default function FarmAiAssistantChat() {
}
await sleep(1500)
taskStatus = await farmAiAssistantService.getChatTaskStatus(task.task_id)
taskStatus = await farmAiAssistantService.getChatTaskStatus(task.task_id, farmUuid)
}
if (taskStatus.status === 'FAILURE' || !taskStatus.result) {
@@ -205,6 +231,11 @@ export default function FarmAiAssistantChat() {
}
const handleNewChat = async () => {
if (!farmUuid) {
toast.error(t('errors.noFarm'))
return
}
setConversationId(null)
setMessages([])
setSelectedChip(null)
@@ -214,6 +245,7 @@ export default function FarmAiAssistantChat() {
try {
const conversation = await farmAiAssistantService.createConversation({
farm_uuid: farmUuid,
title: t('sidebar.newChat'),
farm_context: farmContext
})
@@ -227,13 +259,18 @@ export default function FarmAiAssistantChat() {
}
const handleSelectConversation = async (id: string) => {
if (!farmUuid) {
toast.error(t('errors.noFarm'))
return
}
setConversationId(id)
setMessages([])
setIsTyping(true)
setSidebarOpen(false)
try {
const data = await farmAiAssistantService.getConversationMessages(id)
const data = await farmAiAssistantService.getConversationMessages(id, farmUuid)
setMessages(data.messages.map(mapApiMessageToUi))
} catch {
@@ -37,6 +37,7 @@ export interface FarmAIMessage {
export interface ConversationSummary {
id: string
farm_uuid?: string
message_count: number
title?: string
updated_at?: string
@@ -10,6 +10,7 @@ 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 { useFarmHub } from "@/hooks/useFarmHub";
import type {
FarmData,
GrowthStage,
@@ -54,6 +55,8 @@ const getErrorMessage = (error: unknown, fallback: string) =>
export default function SmartFertilizationRecommendation() {
const t = useTranslations("fertilization");
const theme = useTheme();
const { farmHub } = useFarmHub();
const farmUuid = farmHub?.farm_uuid;
const primaryMain = theme.palette.primary.main;
const primaryLight = theme.palette.primary.light;
const primaryDark = theme.palette.primary.dark;
@@ -77,8 +80,19 @@ export default function SmartFertilizationRecommendation() {
const [reasoningExpanded, setReasoningExpanded] = useState(false);
useEffect(() => {
setPlan(null);
setRequestError(null);
if (!farmUuid) {
setConfigError(t("errors.noFarm"));
setConfigLoading(false);
return;
}
setConfigLoading(true);
setConfigError(null);
fertilizationRecommendationService
.getConfig()
.getConfig(farmUuid)
.then(({ farmData: farm, growthStages: stages, cropOptions: crops }) => {
if (farm) setFarmData(farm);
if (stages?.length) {
@@ -91,10 +105,10 @@ export default function SmartFertilizationRecommendation() {
setConfigError(err?.message ?? "Failed to load config");
})
.finally(() => setConfigLoading(false));
}, []);
}, [farmUuid, t]);
const handleGenerate = async () => {
if (!selectedCrop) return;
if (!selectedCrop || !farmUuid) return;
setLoading(true);
setPlan(null);
setRequestError(null);
@@ -103,6 +117,7 @@ export default function SmartFertilizationRecommendation() {
try {
const recommendation = await fertilizationRecommendationService.recommend(
{
farm_uuid: farmUuid,
crop_id: selectedCrop,
growth_stage: growthStage,
farm_data: {
@@ -121,6 +136,7 @@ export default function SmartFertilizationRecommendation() {
let taskStatus =
await fertilizationRecommendationService.getRecommendStatus(
recommendation.task_id,
farmUuid,
);
while (isRecommendationTaskRunning(taskStatus.status)) {
@@ -135,6 +151,7 @@ export default function SmartFertilizationRecommendation() {
taskStatus =
await fertilizationRecommendationService.getRecommendStatus(
recommendation.task_id,
farmUuid,
);
}
@@ -10,6 +10,7 @@ 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 { useFarmHub } from "@/hooks/useFarmHub";
import type {
FarmInfo,
CropOption,
@@ -37,6 +38,8 @@ const getErrorMessage = (error: unknown, fallback: string) =>
export default function SmartIrrigationRecommendation() {
const t = useTranslations("irrigation");
const theme = useTheme();
const { farmHub } = useFarmHub();
const farmUuid = farmHub?.farm_uuid;
const [farmInfo, setFarmInfo] = useState<FarmInfo>(DEFAULT_FARM_INFO);
const [cropOptions, setCropOptions] = useState<CropOption[]>([]);
const [configLoading, setConfigLoading] = useState(true);
@@ -53,8 +56,20 @@ export default function SmartIrrigationRecommendation() {
const paperBg = theme.palette.background.paper;
useEffect(() => {
setPlan(null);
setWaterBalance(null);
setRequestError(null);
if (!farmUuid) {
setConfigError(t("errors.noFarm"));
setConfigLoading(false);
return;
}
setConfigLoading(true);
setConfigError(null);
irrigationRecommendationService
.getConfig()
.getConfig(farmUuid)
.then(({ farmInfo: info, cropOptions: crops }) => {
setFarmInfo(info);
setCropOptions(crops.length > 0 ? crops : []);
@@ -63,10 +78,10 @@ export default function SmartIrrigationRecommendation() {
setConfigError(err?.message ?? "Failed to load config");
})
.finally(() => setConfigLoading(false));
}, []);
}, [farmUuid, t]);
const handleGenerate = async () => {
if (!selectedCrop) return;
if (!selectedCrop || !farmUuid) return;
setLoading(true);
setPlan(null);
setWaterBalance(null);
@@ -74,6 +89,7 @@ export default function SmartIrrigationRecommendation() {
setStatusMessage(t("generating"));
try {
const recommendation = await irrigationRecommendationService.recommend({
farm_uuid: farmUuid,
crop_id: selectedCrop,
farm_data: {
soilType: farmInfo.soilType,
@@ -90,6 +106,7 @@ export default function SmartIrrigationRecommendation() {
let taskStatus =
await irrigationRecommendationService.getRecommendStatus(
recommendation.task_id,
farmUuid,
);
while (isRecommendationTaskRunning(taskStatus.status)) {
@@ -103,6 +120,7 @@ export default function SmartIrrigationRecommendation() {
await sleep(1500);
taskStatus = await irrigationRecommendationService.getRecommendStatus(
recommendation.task_id,
farmUuid,
);
}
@@ -13,13 +13,13 @@ import CardContent from '@mui/material/CardContent'
import Fade from '@mui/material/Fade'
// Hook Imports
import { useSensorHub } from '@/hooks/useSensorHub'
import { useFarmHub } from '@/hooks/useFarmHub'
// API Imports
import type { Sensor } from '@/libs/api/services/sensorHubService'
import { farmHubService } from '@/libs/api/services/farmHubService'
import type { Farm } from '@/libs/api/services/farmHubService'
// Component Imports
import SensorHubTable from '@views/sensorHub/SensorHubTable'
import OptionSensorHub from '@views/sensorHub/OptionSensorHub'
import FormSensorHub from '@views/sensorHub/FormSensorHub'
@@ -28,12 +28,13 @@ const transitionTimeout = { enter: 300, exit: 200 }
const SensorHubTabContent = () => {
const t = useTranslations('sensorHub')
const [showAddForm, setShowAddForm] = useState(false)
const { setSensorHub } = useSensorHub()
const { setFarmHub } = useFarmHub()
const handleBack = () => setShowAddForm(false)
const handleConfirm = (sensor: Sensor) => {
setSensorHub({ id: sensor.uuid_sensor, ...sensor })
const handleConfirm = (farm: Farm) => {
setFarmHub({ id: farm.farm_uuid, ...farm })
farmHubService.activateFarm(farm.farm_uuid).catch(() => {})
}
return (
+18 -9
View File
@@ -13,7 +13,7 @@ import Alert from '@mui/material/Alert'
import Typography from '@mui/material/Typography'
// API Imports
import { sensorHubService } from '@/libs/api'
import { farmHubService } from '@/libs/api'
// Component Imports
import CustomTextField from '@core/components/mui/TextField'
@@ -51,7 +51,7 @@ const PLANT_NAMES_BY_TYPE: Record<string, string[]> = {
const FormSensorHub = ({ onBack }: FormSensorHubProps) => {
const t = useTranslations('sensorHub')
const [name, setName] = useState('')
const [uuidSensor, setUuidSensor] = useState('')
const [stationName, setStationName] = useState('')
const [plantType, setPlantType] = useState<string | null>(null)
const [plantName, setPlantName] = useState<string | null>(null)
const [areaGeoJson, setAreaGeoJson] = useState<MapDrawGeoJSON>(null)
@@ -68,11 +68,20 @@ const FormSensorHub = ({ onBack }: FormSensorHubProps) => {
e.preventDefault()
setError(null)
setLoading(true)
console.log(areaGeoJson)
try {
await sensorHubService.addSensor({
await farmHubService.createFarm({
name,
uuid_sensor: uuidSensor,
...(stationName
? {
sensors: [
{
name: stationName,
sensor_type: 'weather_station',
is_active: true
}
]
}
: {}),
...(areaGeoJson && { area_geojson: areaGeoJson }),
...(areaM2 !== undefined && { area_m2: areaM2 })
})
@@ -120,10 +129,10 @@ const FormSensorHub = ({ onBack }: FormSensorHubProps) => {
<Grid size={{ xs: 12 }}>
<CustomTextField
fullWidth
label={t('sensorUuid')}
placeholder={t('placeholderUuid')}
value={uuidSensor}
onChange={e => setUuidSensor(e.target.value)}
label={t('stationName')}
placeholder={t('placeholderStationName')}
value={stationName}
onChange={e => setStationName(e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
+24 -17
View File
@@ -16,14 +16,15 @@ import DateObject from 'react-date-object'
import type { CustomInputHorizontalData } from '@core/components/custom-inputs/types'
// API Imports
import { sensorHubService } from '@/libs/api'
import type { Sensor } from '@/libs/api/services/sensorHubService'
import { farmHubService } from '@/libs/api'
import { getStoredFarmHub } from '@/hooks/useFarmHub'
import type { Farm } from '@/libs/api/services/farmHubService'
// Component Imports
import CustomInputHorizontal from '@core/components/custom-inputs/Horizontal'
type OptionSensorHubProps = {
onConfirm?: (sensor: Sensor) => void
onConfirm?: (farm: Farm) => void
}
const formatLastUpdated = (dateStr: string | null | undefined): string => {
@@ -37,27 +38,33 @@ const formatLastUpdated = (dateStr: string | null | undefined): string => {
}
}
const sensorToOption = (sensor: Sensor, isFirst: boolean): CustomInputHorizontalData => ({
title: sensor.name,
meta: formatLastUpdated(sensor.last_updated),
content: sensor.uuid_sensor,
value: sensor.uuid_sensor,
isSelected: isFirst
const farmToOption = (farm: Farm, isSelected: boolean): CustomInputHorizontalData => ({
title: farm.name,
meta: formatLastUpdated(farm.last_updated),
content: farm.farm_uuid,
value: farm.farm_uuid,
isSelected
})
const OptionSensorHub = ({ onConfirm }: OptionSensorHubProps) => {
const [sensors, setSensors] = useState<Sensor[]>([])
const [farms, setFarms] = useState<Farm[]>([])
const [data, setData] = useState<CustomInputHorizontalData[]>([])
const [loading, setLoading] = useState(true)
const [selectedOption, setSelectedOption] = useState<string>('')
useEffect(() => {
const fetchSensors = async () => {
const fetchFarms = async () => {
try {
setLoading(true)
const sensorsList = await sensorHubService.listSensors()
setSensors(sensorsList)
const options = sensorsList.map((s, i) => sensorToOption(s, i === 0))
const farmsList = await farmHubService.listFarms()
const storedFarmUuid = getStoredFarmHub()?.farm_uuid
const selectedFarmUuid =
farmsList.find(farm => farm.farm_uuid === storedFarmUuid)?.farm_uuid ??
farmsList.find(farm => farm.is_active)?.farm_uuid ??
farmsList[0]?.farm_uuid
setFarms(farmsList)
const options = farmsList.map(farm => farmToOption(farm, farm.farm_uuid === selectedFarmUuid))
setData(options)
if (options.length > 0) {
const selected = options.find(o => o.isSelected) ?? options[0]
@@ -65,14 +72,14 @@ const OptionSensorHub = ({ onConfirm }: OptionSensorHubProps) => {
setSelectedOption(selected.value)
}
} catch {
setSensors([])
setFarms([])
setData([])
} finally {
setLoading(false)
}
}
fetchSensors()
fetchFarms()
}, [])
const handleOptionChange = (prop: string | ChangeEvent<HTMLInputElement>) => {
@@ -96,7 +103,7 @@ const OptionSensorHub = ({ onConfirm }: OptionSensorHubProps) => {
}
const handleConfirm = () => {
const selected = sensors.find(s => s.uuid_sensor === selectedOption)
const selected = farms.find(farm => farm.farm_uuid === selectedOption)
if (selected && onConfirm) {
onConfirm(selected)
}
+9 -9
View File
@@ -14,8 +14,8 @@ import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '
import DateObject from 'react-date-object'
// API Imports
import { sensorHubService } from '@/libs/api'
import type { Sensor } from '@/libs/api/services/sensorHubService'
import { farmHubService } from '@/libs/api'
import type { Farm } from '@/libs/api/services/farmHubService'
// Style Imports
import styles from '@core/styles/table.module.css'
@@ -32,7 +32,7 @@ const formatToShamsi = (dateStr: string | null | undefined): string => {
}
// Column Definitions
const columnHelper = createColumnHelper<Sensor>()
const columnHelper = createColumnHelper<Farm>()
const SensorHubTable = () => {
const t = useTranslations('sensorHub')
@@ -47,24 +47,24 @@ const SensorHubTable = () => {
cell: info => formatToShamsi(info.getValue()),
header: t('columns.lastUpdate')
}),
columnHelper.accessor('uuid_sensor', {
columnHelper.accessor('farm_uuid', {
cell: info => info.getValue(),
header: t('columns.uuid')
})
],
[t]
)
const [data, setData] = useState<Sensor[]>([])
const [data, setData] = useState<Farm[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchSensors = async () => {
const fetchFarms = async () => {
try {
setLoading(true)
setError(null)
const sensors = await sensorHubService.listSensors()
setData(sensors)
const farms = await farmHubService.listFarms()
setData(farms)
} catch (err) {
setError(err instanceof Error ? err.message : t('errorLoad'))
setData([])
@@ -73,7 +73,7 @@ const SensorHubTable = () => {
}
}
fetchSensors()
fetchFarms()
}, [])
const table = useReactTable({
+30 -29
View File
@@ -6,13 +6,13 @@ import type { Theme } from '@mui/material/styles'
import { useTranslations } from 'next-intl'
// Hook Imports
import { useSensorHub } from '@/hooks/useSensorHub'
import { useFarmHub } from '@/hooks/useFarmHub'
// API Imports
import type { Sensor } from '@/libs/api/services/sensorHubService'
import { farmHubService } from '@/libs/api/services/farmHubService'
import type { Farm } from '@/libs/api/services/farmHubService'
import Dialog from '@mui/material/Dialog'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import Drawer from '@mui/material/Drawer'
import Button from '@mui/material/Button'
import IconButton from '@mui/material/IconButton'
@@ -37,17 +37,17 @@ const DialogContentWithTransition = ({
onShowAddForm,
onBack,
onConfirm,
selectSensor,
selectSensorDescription,
addSensor
selectFarm,
selectFarmDescription,
addFarm
}: {
showAddForm: boolean
onShowAddForm: () => void
onBack: () => void
onConfirm: (sensor: Sensor) => void
selectSensor: string
selectSensorDescription: string
addSensor: string
onConfirm: (farm: Farm) => void
selectFarm: string
selectFarmDescription: string
addFarm: string
}) => (
<Fade key={showAddForm ? 'form' : 'options'} in timeout={transitionTimeout}>
<div>
@@ -58,10 +58,10 @@ const DialogContentWithTransition = ({
<div className='grid grid-cols-[1fr_auto] items-center gap-4'>
<div className='flex flex-col gap-0.5'>
<Typography variant='h6' fontWeight={600}>
{selectSensor}
{selectFarm}
</Typography>
<Typography variant='body2' color='text.secondary' sx={{ lineHeight: 1.5 }}>
{selectSensorDescription}
{selectFarmDescription}
</Typography>
</div>
<Button
@@ -70,7 +70,7 @@ const DialogContentWithTransition = ({
startIcon={<i className='tabler-plus text-xl' />}
onClick={onShowAddForm}
>
{addSensor}
{addFarm}
</Button>
</div>
<OptionSensorHub onConfirm={onConfirm} />
@@ -85,17 +85,17 @@ const DrawerContentWithTransition = ({
onShowAddForm,
onBack,
onConfirm,
selectSensor,
selectSensorDescription,
addSensor
selectFarm,
selectFarmDescription,
addFarm
}: {
showAddForm: boolean
onShowAddForm: () => void
onBack: () => void
onConfirm: (sensor: Sensor) => void
selectSensor: string
selectSensorDescription: string
addSensor: string
onConfirm: (farm: Farm) => void
selectFarm: string
selectFarmDescription: string
addFarm: string
}) => (
<Fade key={showAddForm ? 'form' : 'options'} in timeout={transitionTimeout}>
<div>
@@ -106,10 +106,10 @@ const DrawerContentWithTransition = ({
<div className='grid grid-cols-[1fr_auto] items-center gap-4'>
<div className='flex flex-col gap-0.5'>
<Typography variant='h6' fontWeight={600}>
{selectSensor}
{selectFarm}
</Typography>
<Typography variant='body2' color='text.secondary' sx={{ lineHeight: 1.5 }}>
{selectSensorDescription}
{selectFarmDescription}
</Typography>
</div>
<Button
@@ -118,7 +118,7 @@ const DrawerContentWithTransition = ({
startIcon={<i className='tabler-plus text-xl' />}
onClick={onShowAddForm}
>
{addSensor}
{addFarm}
</Button>
</div>
<OptionSensorHub onConfirm={onConfirm} />
@@ -132,18 +132,19 @@ const TableModalSheet = ({ open, onClose }: TableModalSheetProps) => {
const t = useTranslations('sensorHub')
const [showAddForm, setShowAddForm] = useState(false)
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm'))
const { setSensorHub } = useSensorHub()
const { setFarmHub } = useFarmHub()
const contentProps = {
selectSensor: t('selectSensor'),
selectSensorDescription: t('selectSensorDescription'),
addSensor: t('addSensor')
selectFarm: t('selectSensor'),
selectFarmDescription: t('selectSensorDescription'),
addFarm: t('addSensor')
}
const handleBack = () => setShowAddForm(false)
const handleConfirm = (sensor: Sensor) => {
setSensorHub({ id: sensor.uuid_sensor, ...sensor })
const handleConfirm = (farm: Farm) => {
setFarmHub({ id: farm.farm_uuid, ...farm })
farmHubService.activateFarm(farm.farm_uuid).catch(() => {})
onClose()
}