This commit is contained in:
2026-03-26 15:25:17 +03:30
parent 4b291155d0
commit e89c3a1b16
7 changed files with 425 additions and 249 deletions
+9 -6
View File
@@ -5,22 +5,28 @@ This document describes all environment variables needed for the frontend applic
## Required Environment Variables ## Required Environment Variables
### Server Configuration ### Server Configuration
- `PORT` - Server port (default: 9031) - `PORT` - Server port (default: 9031)
- `NODE_ENV` - Node environment (production/development) - `NODE_ENV` - Node environment (production/development)
### Next.js Configuration ### Next.js Configuration
- `BASEPATH` - Base path for Next.js application (optional, leave empty for root) - `BASEPATH` - Base path for Next.js application (optional, leave empty for root)
- `NEXT_PUBLIC_APP_URL` - Public URL of the application (e.g., http://localhost:9031) - `NEXT_PUBLIC_APP_URL` - Public URL of the application (e.g., http://localhost:9031)
- `NEXT_PUBLIC_DOCS_URL` - Documentation URL (optional) - `NEXT_PUBLIC_DOCS_URL` - Documentation URL (optional)
### API Configuration ### API Configuration
- `NEXT_PUBLIC_API_URL` or `ENVOY_GATEWAY_URL` - Envoy Gateway URL for backend API calls (e.g., http://85.208.253.135:8000)
- This is used by the frontend to communicate with backend services via Envoy Gateway - `NEXT_PUBLIC_API_URL` - Public API URL for browser-side frontend requests (e.g., http://85.208.253.135:8000)
- Defaults to `http://85.208.253.135:8000` if not set - Use this for anything that runs in the browser
- `ENVOY_GATEWAY_URL` - Server-side fallback API URL
- This is only available in Next.js server runtime and is not exposed to the browser
- Defaults to `http://85.208.253.135:8000` if neither variable is set
## Optional Environment Variables ## Optional Environment Variables
### Mapbox ### Mapbox
- `MAPBOX_ACCESS_TOKEN` - Mapbox access token for map features - `MAPBOX_ACCESS_TOKEN` - Mapbox access token for map features
## Example .env file ## Example .env file
@@ -47,6 +53,3 @@ MAPBOX_ACCESS_TOKEN=your-mapbox-access-token
When using Docker, these environment variables are set in `docker-compose.yaml`. When using Docker, these environment variables are set in `docker-compose.yaml`.
For local development, create a `.env` file in the frontend directory with the values above. For local development, create a `.env` file in the frontend directory with the values above.
+14 -3
View File
@@ -8,6 +8,7 @@ import type { ReactNode } from "react";
import { import {
authService, authService,
type AuthResponse, type AuthResponse,
type AuthTokenValue,
type AuthUser, type AuthUser,
type RegisterRequest, type RegisterRequest,
} from "@/libs/api/services/authService"; } from "@/libs/api/services/authService";
@@ -44,6 +45,16 @@ const STORAGE_KEYS = {
authUser: "auth_user", authUser: "auth_user",
} as const; } as const;
const hasAuthToken = (token: AuthTokenValue | null | undefined) => {
if (!token) return false;
if (typeof token === "string") {
return token.trim().length > 0;
}
return typeof token.access === "string" && token.access.trim().length > 0;
};
export const AuthProvider = ({ children }: AuthProviderProps) => { export const AuthProvider = ({ children }: AuthProviderProps) => {
const [user, setUser] = useState<AuthUser | null>(null); const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -108,7 +119,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
response: AuthResponse, response: AuthResponse,
fallbackMessage: string, fallbackMessage: string,
): LoginResult => { ): LoginResult => {
if (!response.data || !response.token?.access) { if (!response.data || !hasAuthToken(response.token)) {
throw new Error(response.msg || fallbackMessage); throw new Error(response.msg || fallbackMessage);
} }
@@ -127,7 +138,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
): Promise<LoginResult> => { ): Promise<LoginResult> => {
const response = await authService.login({ identifier, password }); const response = await authService.login({ identifier, password });
if (response.code !== 200) { if (response.code >= 400) {
throw new Error(response.msg || "Login failed"); throw new Error(response.msg || "Login failed");
} }
@@ -137,7 +148,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
const register = async (payload: RegisterPayload): Promise<LoginResult> => { const register = async (payload: RegisterPayload): Promise<LoginResult> => {
const response = await authService.register(payload); const response = await authService.register(payload);
if (response.code !== 200 && response.code !== 201) { if (response.code >= 400) {
throw new Error(response.msg || "Registration failed"); throw new Error(response.msg || "Registration failed");
} }
+8 -5
View File
@@ -2,10 +2,13 @@
* API Client for communicating with Backend via Envoy Gateway * API Client for communicating with Backend via Envoy Gateway
*/ */
const API_BASE_URL = const resolveApiBaseUrl = (): string => {
process.env.NEXT_PUBLIC_API_URL || const publicApiUrl = process.env.NEXT_PUBLIC_API_URL;
process.env.ENVOY_GATEWAY_URL || const serverApiUrl =
"http://node.crop-logic.ir"; typeof window === "undefined" ? process.env.ENVOY_GATEWAY_URL : undefined;
return publicApiUrl || serverApiUrl || "http://node.crop-logic.ir";
};
const AUTH_STORAGE_KEYS = { const AUTH_STORAGE_KEYS = {
accessToken: "auth_token", accessToken: "auth_token",
@@ -22,7 +25,7 @@ export class ApiClient {
private baseURL: string; private baseURL: string;
private defaultHeaders: Record<string, string>; private defaultHeaders: Record<string, string>;
constructor(baseURL: string = API_BASE_URL) { constructor(baseURL: string = resolveApiBaseUrl()) {
this.baseURL = baseURL.replace(/\/$/, ""); // Remove trailing slash this.baseURL = baseURL.replace(/\/$/, ""); // Remove trailing slash
this.defaultHeaders = { this.defaultHeaders = {
"Content-Type": "application/json", "Content-Type": "application/json",
+24 -8
View File
@@ -16,9 +16,11 @@ export interface AuthUser {
export interface AuthTokens { export interface AuthTokens {
access: string; access: string;
refresh: string; refresh?: string;
} }
export type AuthTokenValue = string | AuthTokens;
export interface LoginRequest { export interface LoginRequest {
identifier: string; identifier: string;
password: string; password: string;
@@ -37,7 +39,7 @@ export interface AuthResponse {
code: number; code: number;
msg: string; msg: string;
data: AuthUser; data: AuthUser;
token: AuthTokens; token: AuthTokenValue;
} }
export interface RefreshTokenResponse { export interface RefreshTokenResponse {
@@ -56,6 +58,24 @@ export interface UpdateProfileResponse {
data: AuthUser; data: AuthUser;
} }
const storeAuthToken = (token: AuthTokenValue | null | undefined): void => {
if (!token) return;
if (typeof token === "string") {
apiClient.setAuthToken(token);
return;
}
if (token.access) {
apiClient.setAuthToken(token.access);
}
if (token.refresh) {
apiClient.setRefreshToken(token.refresh);
}
};
export const authService = { export const authService = {
/** /**
* Login with username, email, or phone number * Login with username, email, or phone number
@@ -66,9 +86,7 @@ export const authService = {
payload, payload,
); );
if (response.token?.access) { storeAuthToken(response.token);
apiClient.setAuthTokens(response.token);
}
return response; return response;
}, },
@@ -82,9 +100,7 @@ export const authService = {
payload, payload,
); );
if (response.token?.access) { storeAuthToken(response.token);
apiClient.setAuthTokens(response.token);
}
return response; return response;
}, },
+171 -75
View File
@@ -5,84 +5,166 @@
* - Cards: all 15 card payloads from /api/farm-dashboard/ * - Cards: all 15 card payloads from /api/farm-dashboard/
*/ */
import { apiClient } from '../client' import { apiClient } from "../client";
import type { FarmDashboardConfig } from '@/views/dashboards/farm/farmDashboardConfig' import {
import type { CardId } from '@/views/dashboards/farm/farmDashboardConfig' CARD_IDS,
CARD_TO_ROW,
ROW_CARDS,
ROW_IDS,
type FarmDashboardConfig,
type CardId,
type RowId,
} from "@/views/dashboards/farm/farmDashboardConfig";
export interface ApiResponse<T> { export interface ApiResponse<T> {
code: number code: number;
msg: string msg: string;
data: T data: T;
} }
export interface FarmDashboardConfigResponse { export interface FarmDashboardConfigResponse {
disabled_card_ids: string[] disabled_card_ids: string[];
row_order: string[] row_order: string[];
enable_drag_reorder?: boolean enable_drag_reorder?: boolean;
} }
/** API response shape for /api/farm-dashboard/ - each key matches CardId */ /** API response shape for /api/farm-dashboard/ - each key matches CardId */
export interface FarmDashboardCardsResponse { export interface FarmDashboardCardsResponse {
farmOverviewKpis?: Record<string, unknown> farmOverviewKpis?: Record<string, unknown>;
farmWeatherCard?: Record<string, unknown> farmWeatherCard?: Record<string, unknown>;
farmAlertsTracker?: Record<string, unknown> farmAlertsTracker?: Record<string, unknown>;
sensorValuesList?: Record<string, unknown> sensorValuesList?: Record<string, unknown>;
sensorRadarChart?: Record<string, unknown> sensorRadarChart?: Record<string, unknown>;
sensorComparisonChart?: Record<string, unknown> sensorComparisonChart?: Record<string, unknown>;
anomalyDetectionCard?: Record<string, unknown> anomalyDetectionCard?: Record<string, unknown>;
farmAlertsTimeline?: Record<string, unknown> farmAlertsTimeline?: Record<string, unknown>;
waterNeedPrediction?: Record<string, unknown> waterNeedPrediction?: Record<string, unknown>;
harvestPredictionCard?: Record<string, unknown> harvestPredictionCard?: Record<string, unknown>;
yieldPredictionChart?: Record<string, unknown> yieldPredictionChart?: Record<string, unknown>;
soilMoistureHeatmap?: Record<string, unknown> soilMoistureHeatmap?: Record<string, unknown>;
ndviHealthCard?: Record<string, unknown> ndviHealthCard?: Record<string, unknown>;
recommendationsList?: Record<string, unknown> recommendationsList?: Record<string, unknown>;
economicOverview?: Record<string, unknown> economicOverview?: Record<string, unknown>;
} }
const STORAGE_KEY = 'farm_dashboard_config' interface FarmDashboardCardsTaskResult {
sensor_id?: string;
all_cards?: FarmDashboardCardsResponse;
}
interface FarmDashboardCardsTaskData {
task_id?: string;
status?: string;
result?: FarmDashboardCardsTaskResult;
}
const STORAGE_KEY = "farm_dashboard_config";
function isCardId(value: string): value is CardId {
return (CARD_IDS as readonly string[]).includes(value);
}
function isRowId(value: string): value is RowId {
return (ROW_IDS as readonly string[]).includes(value);
}
function normalizeDisabledCardIds(disabledIds: string[] = []): string[] {
return Array.from(
new Set(
disabledIds.flatMap((id) => {
if (isCardId(id)) return [id];
if (isRowId(id)) return ROW_CARDS[id];
return [];
}),
),
);
}
function normalizeRowOrder(rowOrder: string[] = []): string[] {
const normalized = rowOrder
.map((id) => {
if (isRowId(id)) return id;
if (isCardId(id)) return CARD_TO_ROW[id];
return null;
})
.filter((id): id is RowId => !!id);
const deduped = Array.from(new Set(normalized));
return deduped.length
? [...deduped, ...ROW_IDS.filter((id) => !deduped.includes(id))]
: [...ROW_IDS];
}
function extractCardsPayload(
response:
| ApiResponse<FarmDashboardCardsResponse>
| ApiResponse<FarmDashboardCardsTaskData>
| FarmDashboardCardsResponse
| FarmDashboardCardsTaskData,
): Partial<Record<CardId, Record<string, unknown>>> {
const raw = response && "data" in response ? response.data : response;
if (!raw || typeof raw !== "object") {
return {};
}
if ("result" in raw && raw.result && typeof raw.result === "object") {
return (raw.result.all_cards ?? {}) as Partial<
Record<CardId, Record<string, unknown>>
>;
}
return raw as Partial<Record<CardId, Record<string, unknown>>>;
}
/** /**
* Transform API response to frontend config format * Transform API response to frontend config format
*/ */
function fromApiResponse(data: FarmDashboardConfigResponse): FarmDashboardConfig { function fromApiResponse(
data: FarmDashboardConfigResponse,
): FarmDashboardConfig {
return { return {
disabledCardIds: data.disabled_card_ids ?? [], disabledCardIds: normalizeDisabledCardIds(data.disabled_card_ids),
rowOrder: data.row_order ?? [], rowOrder: normalizeRowOrder(data.row_order),
enableDragReorder: data.enable_drag_reorder ?? true enableDragReorder: data.enable_drag_reorder ?? true,
} };
} }
/** /**
* Transform frontend config to API request format * Transform frontend config to API request format
*/ */
function toApiRequest(config: Partial<FarmDashboardConfig>): Partial<FarmDashboardConfigResponse> { function toApiRequest(
const req: Partial<FarmDashboardConfigResponse> = {} config: Partial<FarmDashboardConfig>,
if (config.disabledCardIds !== undefined) req.disabled_card_ids = config.disabledCardIds ): Partial<FarmDashboardConfigResponse> {
if (config.rowOrder !== undefined) req.row_order = config.rowOrder const req: Partial<FarmDashboardConfigResponse> = {};
if (config.enableDragReorder !== undefined) req.enable_drag_reorder = config.enableDragReorder if (config.disabledCardIds !== undefined)
return req req.disabled_card_ids = config.disabledCardIds;
if (config.rowOrder !== undefined) req.row_order = config.rowOrder;
if (config.enableDragReorder !== undefined)
req.enable_drag_reorder = config.enableDragReorder;
return req;
} }
/** /**
* localStorage fallback when backend is not ready * localStorage fallback when backend is not ready
*/ */
function getLocalConfig(): FarmDashboardConfig | null { function getLocalConfig(): FarmDashboardConfig | null {
if (typeof window === 'undefined') return null if (typeof window === "undefined") return null;
try { try {
const stored = localStorage.getItem(STORAGE_KEY) const stored = localStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as FarmDashboardConfig) : null return stored ? (JSON.parse(stored) as FarmDashboardConfig) : null;
} catch { } catch {
return null return null;
} }
} }
function setLocalConfig(config: FarmDashboardConfig): void { function setLocalConfig(config: FarmDashboardConfig): void {
if (typeof window === 'undefined') return if (typeof window === "undefined") return;
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config)) localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch (e) { } catch (e) {
console.error('Failed to save farm dashboard config to localStorage', e) console.error("Failed to save farm dashboard config to localStorage", e);
} }
} }
@@ -94,46 +176,57 @@ export const farmDashboardService = {
try { try {
const response = await apiClient.get< const response = await apiClient.get<
ApiResponse<FarmDashboardConfigResponse> | FarmDashboardConfigResponse ApiResponse<FarmDashboardConfigResponse> | FarmDashboardConfigResponse
>('/api/farm-dashboard-config') >("/api/farm-dashboard-config");
const raw = response && 'data' in response ? response.data : response const raw = response && "data" in response ? response.data : response;
if (raw && typeof raw === 'object' && ('disabled_card_ids' in raw || 'row_order' in raw)) { if (
return fromApiResponse(raw as FarmDashboardConfigResponse) raw &&
typeof raw === "object" &&
("disabled_card_ids" in raw || "row_order" in raw)
) {
return fromApiResponse(raw as FarmDashboardConfigResponse);
} }
throw new Error('Invalid response') throw new Error("Invalid response");
} catch { } catch {
const local = getLocalConfig() const local = getLocalConfig();
if (local) return local if (local) return local;
return { disabledCardIds: [], rowOrder: [], enableDragReorder: true } return { disabledCardIds: [], rowOrder: [], enableDragReorder: true };
} }
}, },
/** /**
* Update farm dashboard config * Update farm dashboard config
*/ */
async updateConfig(data: Partial<FarmDashboardConfig>): Promise<FarmDashboardConfig> { async updateConfig(
data: Partial<FarmDashboardConfig>,
): Promise<FarmDashboardConfig> {
try { try {
const response = await apiClient.patch< const response = await apiClient.patch<
ApiResponse<FarmDashboardConfigResponse> | FarmDashboardConfigResponse ApiResponse<FarmDashboardConfigResponse> | FarmDashboardConfigResponse
>('/api/farm-dashboard-config', toApiRequest(data)) >("/api/farm-dashboard-config", toApiRequest(data));
const raw = response && 'data' in response ? response.data : response const raw = response && "data" in response ? response.data : response;
if (raw && typeof raw === 'object' && ('disabled_card_ids' in raw || 'row_order' in raw)) { if (
const config = fromApiResponse(raw as FarmDashboardConfigResponse) raw &&
setLocalConfig(config) typeof raw === "object" &&
return config ("disabled_card_ids" in raw || "row_order" in raw)
) {
const config = fromApiResponse(raw as FarmDashboardConfigResponse);
setLocalConfig(config);
return config;
} }
throw new Error('Update failed') throw new Error("Update failed");
} catch (err) { } catch (err) {
const local = getLocalConfig() const local = getLocalConfig();
if (local) { if (local) {
const merged: FarmDashboardConfig = { const merged: FarmDashboardConfig = {
disabledCardIds: data.disabledCardIds ?? local.disabledCardIds, disabledCardIds: data.disabledCardIds ?? local.disabledCardIds,
rowOrder: data.rowOrder ?? local.rowOrder, rowOrder: data.rowOrder ?? local.rowOrder,
enableDragReorder: data.enableDragReorder ?? local.enableDragReorder ?? true enableDragReorder:
data.enableDragReorder ?? local.enableDragReorder ?? true,
};
setLocalConfig(merged);
return merged;
} }
setLocalConfig(merged) throw err;
return merged
}
throw err
} }
}, },
@@ -141,16 +234,19 @@ export const farmDashboardService = {
* Get all dashboard card data from API * Get all dashboard card data from API
* Response: { code: 200, msg: "OK", data: { farmOverviewKpis, farmWeatherCard, ... } } * Response: { code: 200, msg: "OK", data: { farmOverviewKpis, farmWeatherCard, ... } }
*/ */
async getAllCards(): Promise<Partial<Record<CardId, Record<string, unknown>>>> { async getAllCards(): Promise<
Partial<Record<CardId, Record<string, unknown>>>
> {
try { try {
const response = await apiClient.get<ApiResponse<FarmDashboardCardsResponse>>('/api/farm-dashboard/') const response = await apiClient.get<
const raw = response?.data ?? response | ApiResponse<FarmDashboardCardsResponse>
if (raw && typeof raw === 'object') { | ApiResponse<FarmDashboardCardsTaskData>
return raw as Partial<Record<CardId, Record<string, unknown>>> | FarmDashboardCardsResponse
} | FarmDashboardCardsTaskData
return {} >("/api/farm-dashboard/");
return extractCardsPayload(response);
} catch { } catch {
return {} return {};
} }
} },
} };
+196 -150
View File
@@ -1,39 +1,39 @@
'use client' "use client";
// React Imports // React Imports
import type { RefObject } from 'react' import type { RefObject } from "react";
import { useEffect, useMemo, useState, useCallback, useContext } from 'react' import { useEffect, useMemo, useState, useCallback, useContext } from "react";
import { useTranslations } from 'next-intl' import { useTranslations } from "next-intl";
// Context Imports // Context Imports
import NavbarSlotContext from '@/contexts/navbarSlotContext' import NavbarSlotContext from "@/contexts/navbarSlotContext";
// MUI Imports // MUI Imports
import Grid from '@mui/material/Grid2' import Grid from "@mui/material/Grid2";
import IconButton from '@mui/material/IconButton' import IconButton from "@mui/material/IconButton";
import Box from '@mui/material/Box' import Box from "@mui/material/Box";
import CircularProgress from '@mui/material/CircularProgress' import CircularProgress from "@mui/material/CircularProgress";
// Third-party imports // Third-party imports
import { useDragAndDrop } from '@formkit/drag-and-drop/react' import { useDragAndDrop } from "@formkit/drag-and-drop/react";
import { animations } from '@formkit/drag-and-drop' import { animations } from "@formkit/drag-and-drop";
// Component Imports // Component Imports
import FarmOverviewKPIs from '@views/dashboards/farm/FarmOverviewKPIs' import FarmOverviewKPIs from "@views/dashboards/farm/FarmOverviewKPIs";
import FarmWeatherCard from '@views/dashboards/farm/FarmWeatherCard' import FarmWeatherCard from "@views/dashboards/farm/FarmWeatherCard";
import FarmAlertsTracker from '@views/dashboards/farm/FarmAlertsTracker' import FarmAlertsTracker from "@views/dashboards/farm/FarmAlertsTracker";
import SensorValuesList from '@views/dashboards/farm/SensorValuesList' import SensorValuesList from "@views/dashboards/farm/SensorValuesList";
import SensorRadarChart from '@views/dashboards/farm/SensorRadarChart' import SensorRadarChart from "@views/dashboards/farm/SensorRadarChart";
import SensorComparisonChart from '@views/dashboards/farm/SensorComparisonChart' import SensorComparisonChart from "@views/dashboards/farm/SensorComparisonChart";
import FarmAlertsTimeline from '@views/dashboards/farm/FarmAlertsTimeline' import FarmAlertsTimeline from "@views/dashboards/farm/FarmAlertsTimeline";
import WaterNeedPrediction from '@views/dashboards/farm/WaterNeedPrediction' import WaterNeedPrediction from "@views/dashboards/farm/WaterNeedPrediction";
import YieldPredictionChart from '@views/dashboards/farm/YieldPredictionChart' import YieldPredictionChart from "@views/dashboards/farm/YieldPredictionChart";
import HarvestPredictionCard from '@views/dashboards/farm/HarvestPredictionCard' import HarvestPredictionCard from "@views/dashboards/farm/HarvestPredictionCard";
import SoilMoistureHeatmap from '@views/dashboards/farm/SoilMoistureHeatmap' import SoilMoistureHeatmap from "@views/dashboards/farm/SoilMoistureHeatmap";
import AnomalyDetectionCard from '@views/dashboards/farm/AnomalyDetectionCard' import AnomalyDetectionCard from "@views/dashboards/farm/AnomalyDetectionCard";
import NDVIHealthCard from '@views/dashboards/farm/NDVIHealthCard' import NDVIHealthCard from "@views/dashboards/farm/NDVIHealthCard";
import RecommendationsList from '@views/dashboards/farm/RecommendationsList' import RecommendationsList from "@views/dashboards/farm/RecommendationsList";
import EconomicOverview from '@views/dashboards/farm/EconomicOverview' import EconomicOverview from "@views/dashboards/farm/EconomicOverview";
// Config & Service // Config & Service
import { import {
@@ -43,18 +43,21 @@ import {
DEFAULT_FARM_DASHBOARD_CONFIG, DEFAULT_FARM_DASHBOARD_CONFIG,
type RowId, type RowId,
type CardId, type CardId,
type FarmDashboardConfig type FarmDashboardConfig,
} from '@views/dashboards/farm/farmDashboardConfig' } from "@views/dashboards/farm/farmDashboardConfig";
import { farmDashboardService } from '@/libs/api/services/farmDashboardService' import { farmDashboardService } from "@/libs/api/services/farmDashboardService";
import FarmDashboardSettingsDropdown from '@views/dashboards/farm/FarmDashboardSettingsDropdown' import FarmDashboardSettingsDropdown from "@views/dashboards/farm/FarmDashboardSettingsDropdown";
const cardRowSx = { const cardRowSx = {
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
'& > *': { flex: 1, minHeight: 0 } "& > *": { flex: 1, minHeight: 0 },
} };
const CARD_COMPONENTS: Record<CardId, React.ComponentType<{ data?: Record<string, unknown> }>> = { const CARD_COMPONENTS: Record<
CardId,
React.ComponentType<{ data?: Record<string, unknown> }>
> = {
farmOverviewKpis: FarmOverviewKPIs, farmOverviewKpis: FarmOverviewKPIs,
farmWeatherCard: FarmWeatherCard, farmWeatherCard: FarmWeatherCard,
farmAlertsTracker: FarmAlertsTracker, farmAlertsTracker: FarmAlertsTracker,
@@ -69,158 +72,179 @@ const CARD_COMPONENTS: Record<CardId, React.ComponentType<{ data?: Record<string
soilMoistureHeatmap: SoilMoistureHeatmap, soilMoistureHeatmap: SoilMoistureHeatmap,
ndviHealthCard: NDVIHealthCard, ndviHealthCard: NDVIHealthCard,
recommendationsList: RecommendationsList, recommendationsList: RecommendationsList,
economicOverview: EconomicOverview economicOverview: EconomicOverview,
} };
function mergeRowOrderAfterDrag( function mergeRowOrderAfterDrag(
currentRowOrder: string[], currentRowOrder: string[],
newVisibleOrder: string[], newVisibleOrder: string[],
visibleRows: string[] visibleRows: string[],
): string[] { ): string[] {
const result = [...currentRowOrder] const result = [...currentRowOrder];
let visibleIndex = 0 let visibleIndex = 0;
for (let i = 0; i < result.length; i++) { for (let i = 0; i < result.length; i++) {
if (visibleRows.includes(result[i])) { if (visibleRows.includes(result[i])) {
result[i] = newVisibleOrder[visibleIndex++] result[i] = newVisibleOrder[visibleIndex++];
} }
} }
return result return result;
} }
const FarmDashboardWrapper = () => { const FarmDashboardWrapper = () => {
const t = useTranslations('farmDashboard') const t = useTranslations("farmDashboard");
const { setSlotContent } = useContext(NavbarSlotContext) const { setSlotContent } = useContext(NavbarSlotContext);
const [config, setConfig] = useState<FarmDashboardConfig>(DEFAULT_FARM_DASHBOARD_CONFIG) const [config, setConfig] = useState<FarmDashboardConfig>(
DEFAULT_FARM_DASHBOARD_CONFIG,
);
const cardLabels = useMemo( const cardLabels = useMemo(
() => () =>
Object.fromEntries( Object.fromEntries(
( (
[ [
'farmOverviewKpis', "farmOverviewKpis",
'farmWeatherCard', "farmWeatherCard",
'farmAlertsTracker', "farmAlertsTracker",
'sensorValuesList', "sensorValuesList",
'sensorRadarChart', "sensorRadarChart",
'sensorComparisonChart', "sensorComparisonChart",
'anomalyDetectionCard', "anomalyDetectionCard",
'farmAlertsTimeline', "farmAlertsTimeline",
'waterNeedPrediction', "waterNeedPrediction",
'harvestPredictionCard', "harvestPredictionCard",
'yieldPredictionChart', "yieldPredictionChart",
'soilMoistureHeatmap', "soilMoistureHeatmap",
'ndviHealthCard', "ndviHealthCard",
'recommendationsList', "recommendationsList",
'economicOverview' "economicOverview",
] as CardId[] ] as CardId[]
).map((id) => [id, t(`cards.${id}`)]) ).map((id) => [id, t(`cards.${id}`)]),
) as Record<CardId, string>, ) as Record<CardId, string>,
[t] [t],
) );
const rowLabels = useMemo( const rowLabels = useMemo(
() => () =>
Object.fromEntries( Object.fromEntries(
( (
[ [
'overviewKpis', "overviewKpis",
'weatherAlerts', "weatherAlerts",
'sensorMonitoring', "sensorMonitoring",
'sensorCharts', "sensorCharts",
'alertsWater', "alertsWater",
'predictions', "predictions",
'soilHeatmap', "soilHeatmap",
'ndviRecommendations', "ndviRecommendations",
'economic' "economic",
] as RowId[] ] as RowId[]
).map((id) => [id, t(`rows.${id}`)]) ).map((id) => [id, t(`rows.${id}`)]),
) as Record<RowId, string>, ) as Record<RowId, string>,
[t] [t],
) );
const [cardsData, setCardsData] = useState<Partial<Record<CardId, Record<string, unknown>>>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const disabledSet = new Set(config.disabledCardIds) const [cardsData, setCardsData] = useState<
Partial<Record<CardId, Record<string, unknown>>>
>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const disabledSet = new Set(config.disabledCardIds);
const hasVisibleCard = useCallback( const hasVisibleCard = useCallback(
(rowId: string) => { (rowId: string) => {
const cards = ROW_CARDS[rowId as RowId] const cards = ROW_CARDS[rowId as RowId];
if (!Array.isArray(cards)) return false if (!Array.isArray(cards)) return false;
return cards.some(cardId => !disabledSet.has(cardId)) return cards.some((cardId) => !disabledSet.has(cardId));
}, },
[config.disabledCardIds] [config.disabledCardIds],
) );
const visibleRowOrder = config.rowOrder.filter(hasVisibleCard) const visibleRowOrder = config.rowOrder.filter(hasVisibleCard);
const [containerRef, orderedRows, setOrderedRows] = useDragAndDrop(visibleRowOrder, { const [containerRef, orderedRows, setOrderedRows] = useDragAndDrop(
visibleRowOrder,
{
plugins: [animations()], plugins: [animations()],
dragHandle: '.row-drag-handle' dragHandle: ".row-drag-handle",
}) },
);
// useEffect(()=>{
// console.log("ksjf",visibleRowOrder,orderedRows)
// },[visibleRowOrder,visibleRowOrder])
useEffect(() => { useEffect(() => {
Promise.all([farmDashboardService.getConfig(), farmDashboardService.getAllCards()]) Promise.all([
farmDashboardService.getConfig(),
farmDashboardService.getAllCards(),
])
.then(([configData, cards]) => { .then(([configData, cards]) => {
const validRowOrder = (configData.rowOrder ?? []).filter( const validRowOrder = (configData.rowOrder ?? []).filter(
(id): id is RowId => id in ROW_CARDS (id): id is RowId => id in ROW_CARDS,
) );
const merged: FarmDashboardConfig = { const merged: FarmDashboardConfig = {
disabledCardIds: configData.disabledCardIds ?? [], disabledCardIds: configData.disabledCardIds ?? [],
rowOrder: validRowOrder.length ? validRowOrder : [...ROW_IDS], rowOrder: validRowOrder.length ? validRowOrder : [...ROW_IDS],
enableDragReorder: configData.enableDragReorder ?? true enableDragReorder: configData.enableDragReorder ?? true,
} };
setConfig(merged) setConfig(merged);
setCardsData(cards ?? {}) setCardsData(cards ?? {});
}) })
.catch(() => setConfig(DEFAULT_FARM_DASHBOARD_CONFIG)) .catch(() => setConfig(DEFAULT_FARM_DASHBOARD_CONFIG))
.finally(() => setLoading(false)) .finally(() => setLoading(false));
}, []) }, []);
useEffect(() => { useEffect(() => {
setOrderedRows(visibleRowOrder) setOrderedRows(visibleRowOrder);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.disabledCardIds]) }, [visibleRowOrder]);
useEffect(() => { useEffect(() => {
if (loading) return if (loading) return;
if (JSON.stringify(orderedRows) === JSON.stringify(visibleRowOrder)) return if (JSON.stringify(orderedRows) === JSON.stringify(visibleRowOrder)) return;
const newRowOrder = mergeRowOrderAfterDrag(config.rowOrder, orderedRows, visibleRowOrder) const newRowOrder = mergeRowOrderAfterDrag(
setConfig(prev => ({ ...prev, rowOrder: newRowOrder })) config.rowOrder,
setSaving(true) orderedRows,
visibleRowOrder,
);
setConfig((prev) => ({ ...prev, rowOrder: newRowOrder }));
setSaving(true);
farmDashboardService farmDashboardService
.updateConfig({ rowOrder: newRowOrder }) .updateConfig({ rowOrder: newRowOrder })
.then(updated => setConfig(updated)) .then((updated) => setConfig(updated))
.catch(() => {}) .catch(() => {})
.finally(() => setSaving(false)) .finally(() => setSaving(false));
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [orderedRows]) }, [orderedRows]);
const handleToggleDragReorder = useCallback((enabled: boolean) => { const handleToggleDragReorder = useCallback((enabled: boolean) => {
setConfig(prev => ({ ...prev, enableDragReorder: enabled })) setConfig((prev) => ({ ...prev, enableDragReorder: enabled }));
setSaving(true) setSaving(true);
farmDashboardService farmDashboardService
.updateConfig({ enableDragReorder: enabled }) .updateConfig({ enableDragReorder: enabled })
.then(updated => setConfig(updated)) .then((updated) => setConfig(updated))
.finally(() => setSaving(false)) .finally(() => setSaving(false));
}, []) }, []);
const handleToggleCard = useCallback( const handleToggleCard = useCallback(
(cardId: CardId, disabled: boolean) => { (cardId: CardId, disabled: boolean) => {
const next = disabled const next = disabled
? [...config.disabledCardIds, cardId] ? [...config.disabledCardIds, cardId]
: config.disabledCardIds.filter(id => id !== cardId) : config.disabledCardIds.filter((id) => id !== cardId);
setConfig(prev => ({ ...prev, disabledCardIds: next })) setConfig((prev) => ({ ...prev, disabledCardIds: next }));
setSaving(true) setSaving(true);
farmDashboardService farmDashboardService
.updateConfig({ disabledCardIds: next }) .updateConfig({ disabledCardIds: next })
.then(updated => setConfig(updated)) .then((updated) => setConfig(updated))
.catch(() => setConfig(prev => ({ ...prev, disabledCardIds: next }))) .catch(() => setConfig((prev) => ({ ...prev, disabledCardIds: next })))
.finally(() => setSaving(false)) .finally(() => setSaving(false));
}, },
[config.disabledCardIds] [config.disabledCardIds],
) );
useEffect(() => { useEffect(() => {
setSlotContent( setSlotContent(
@@ -233,77 +257,99 @@ const FarmDashboardWrapper = () => {
rowLabels={rowLabels} rowLabels={rowLabels}
rowCards={ROW_CARDS} rowCards={ROW_CARDS}
saving={saving} saving={saving}
/> />,
) );
return () => setSlotContent(null) return () => setSlotContent(null);
}, [setSlotContent, config.disabledCardIds, config.enableDragReorder, handleToggleCard, handleToggleDragReorder, saving]) }, [
setSlotContent,
config.disabledCardIds,
config.enableDragReorder,
handleToggleCard,
handleToggleDragReorder,
saving,
]);
if (loading) { if (loading) {
return ( return (
<Box display='flex' justifyContent='center' alignItems='center' minHeight={200}> <Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight={200}
>
<CircularProgress /> <CircularProgress />
</Box> </Box>
) );
} }
return ( return (
<Box position='relative'> <Box position="relative">
<Grid container spacing={6} ref={containerRef as RefObject<HTMLDivElement>}> <Grid
container
spacing={6}
ref={containerRef as RefObject<HTMLDivElement>}
>
{orderedRows.map((rowId: string) => { {orderedRows.map((rowId: string) => {
const cards = ROW_CARDS[rowId as RowId].filter(cardId => !disabledSet.has(cardId)) const cards = ROW_CARDS[rowId as RowId].filter(
if (cards.length === 0) return null (cardId) => !disabledSet.has(cardId),
);
if (cards.length === 0) return null;
const isOverviewRow = rowId === 'overviewKpis' const isOverviewRow = rowId === "overviewKpis";
return ( return (
<Grid <Grid
key={rowId} key={rowId}
size={12} size={12}
sx={{ sx={{
display: 'flex', display: "flex",
alignItems: 'flex-start', alignItems: "flex-start",
gap: 2, gap: 2,
...(config.enableDragReorder !== false && { '&:hover .row-drag-handle': { opacity: 1 } }) ...(config.enableDragReorder !== false && {
"&:hover .row-drag-handle": { opacity: 1 },
}),
}} }}
> >
{config.enableDragReorder !== false && ( {config.enableDragReorder !== false && (
<IconButton <IconButton
className='row-drag-handle' className="row-drag-handle"
size='small' size="small"
sx={{ sx={{
opacity: 0.5, opacity: 0.5,
cursor: 'grab', cursor: "grab",
flexShrink: 0, flexShrink: 0,
mt: 1, mt: 1,
'&:active': { cursor: 'grabbing' } "&:active": { cursor: "grabbing" },
}} }}
aria-label={t('settings.dragRow', { row: rowLabels[rowId as RowId] })} aria-label={t("settings.dragRow", {
row: rowLabels[rowId as RowId],
})}
> >
<i className='tabler-arrows-move text-textSecondary' /> <i className="tabler-arrows-move text-textSecondary" />
</IconButton> </IconButton>
)} )}
<Grid container spacing={6} sx={{ flex: 1, minWidth: 0 }}> <Grid container spacing={6} sx={{ flex: 1, minWidth: 0 }}>
{isOverviewRow && cards.includes('farmOverviewKpis') && ( {isOverviewRow && cards.includes("farmOverviewKpis") && (
<FarmOverviewKPIs data={cardsData?.farmOverviewKpis} /> <FarmOverviewKPIs data={cardsData?.farmOverviewKpis} />
)} )}
{!isOverviewRow && {!isOverviewRow &&
cards.map((cardId: CardId) => { cards.map((cardId: CardId) => {
const size = CARD_GRID_SIZE[cardId] const size = CARD_GRID_SIZE[cardId];
const Component = CARD_COMPONENTS[cardId] const Component = CARD_COMPONENTS[cardId];
if (!Component) return null if (!Component) return null;
return ( return (
<Grid key={cardId} size={size} sx={cardRowSx}> <Grid key={cardId} size={size} sx={cardRowSx}>
<Component data={cardsData?.[cardId]} /> <Component data={cardsData?.[cardId]} />
</Grid> </Grid>
) );
})} })}
</Grid> </Grid>
</Grid> </Grid>
) );
})} })}
</Grid> </Grid>
</Box> </Box>
) );
} };
export default FarmDashboardWrapper export default FarmDashboardWrapper;
@@ -25,6 +25,7 @@ const FarmOverviewKPIs = ({ data }: FarmOverviewKPIsProps) => {
const kpis = (data?.kpis as KpiItem[] | undefined) ?? [] const kpis = (data?.kpis as KpiItem[] | undefined) ?? []
if (kpis.length === 0) return null if (kpis.length === 0) return null
return ( return (
<> <>
{kpis.map((kpi) => ( {kpis.map((kpi) => (