Files
dashboard/context/AppContext.tsx
T
2026-07-29 15:02:58 +07:00

1608 lines
58 KiB
TypeScript

import React, {
createContext,
useState,
useCallback,
useContext,
ReactNode,
useEffect,
useMemo,
} from 'react';
// Import types
import {
Kpi,
Alert,
CoopZone,
EnvironmentData,
Camera,
PopulationDataPoint,
LiveCount,
FeedSackLogEntry,
ManualFeedSackEntry,
BehaviorMetrics,
BehaviorDataPoint,
BehaviorAnomaly,
WeightStatistics,
WeightDataPoint,
WeightDistributionPoint,
WeightAlert,
IotDevice,
Location,
Coop,
Floor,
AggregatedCounts,
NetworkDevice,
BandwidthHistoryPoint,
BatchService,
BatchJob,
User,
Group,
Role,
Permission,
RolePermission,
CycleData,
MultiCoopChartPoint,
MultiCoopWeightSummary,
FeedWastageStats,
WastageHotspot,
FeedWastageAlert,
WastageDataPoint,
FeedScheduleRecommendation,
FeedingEvent,
ConsumptionPatternPoint,
ScheduleInfluencingFactors,
ErpSyncStats,
ErpEndpoint,
VetVisit,
OcrDocument,
ManualWeighingRecord,
AiRecommendation,
RecommendationStatus,
CkaleWeightPoint,
FcrDataPoint,
WeightDistributionApiResponse,
WeightDistributionStats,
} from '../types.ts';
// Import custom store hooks
import { useAccessManagementStore } from './stores/useAccessManagementStore.ts';
import { useLocationStore } from './stores/useLocationStore.ts';
import { useDeviceStore } from './stores/useDeviceStore.ts';
import { useStaticDataStore } from './stores/useStaticDataStore.ts';
// Import refactored logic hooks
import { useCrossStoreHandlers } from './hooks/useCrossStoreHandlers.ts';
import { useDerivedState } from './hooks/useDerivedState.ts';
import { useLocationHelpers } from './hooks/useLocationHelpers.ts';
import { apiClient } from '../services/apiService.ts';
import {
processDailyWeightData,
calculateDailyWeightGain,
processDeviceWeightData,
} from '../utils/scaleDataProcessor.ts';
import { cobbStandardPerformance } from '../mockData/cobbStandard.ts';
import {
shouldUsePreviousDay,
getEffectiveDate,
formatDateForDb,
formatEffectiveDateLabel,
} from '../utils/dateFilter.ts';
interface AppContextType {
// State
kpis: Record<string, Kpi>;
alerts: Alert[];
coopZones: CoopZone[];
environmentData: EnvironmentData[];
locations: Location[];
coops: Coop[];
floors: Floor[];
cameras: Camera[];
populationHistory: PopulationDataPoint[];
liveCounts: LiveCount[];
aggregatedCounts: AggregatedCounts;
feedSackHistory: FeedSackLogEntry[];
availableFeedSackCameras: string[];
manualFeedSackEntries: ManualFeedSackEntry[];
behaviorMetrics: BehaviorMetrics;
behaviorHistory: BehaviorDataPoint[];
behaviorAnomalies: BehaviorAnomaly[];
weightStats: WeightStatistics;
weightHistory: WeightDataPoint[];
weightDistribution: WeightDistributionPoint[];
weightAlerts: WeightAlert[];
iotDevices: IotDevice[];
networkDevices: NetworkDevice[];
bandwidthHistory: BandwidthHistoryPoint[];
batchServices: BatchService[];
batchJobs: BatchJob[];
users: User[];
groups: Group[];
roles: Role[];
permissions: Permission[];
rolePermissions: RolePermission[];
currentUser: User | null;
cycleData: CycleData; // The active cycle
cycles: CycleData[]; // All cycles
multiCoopChartData: MultiCoopChartPoint[];
multiCoopSummaryData: MultiCoopWeightSummary[];
coopNamesForChart: string[];
feedWastageStats: FeedWastageStats;
wastageHotspots: WastageHotspot[];
wastageAlerts: FeedWastageAlert[];
wastageHistory: WastageDataPoint[];
feedSchedule: FeedScheduleRecommendation;
feedingEvents: FeedingEvent[];
consumptionPattern: ConsumptionPatternPoint[];
scheduleFactors: ScheduleInfluencingFactors;
erpSyncStats: ErpSyncStats;
erpEndpoints: ErpEndpoint[];
vetVisits: VetVisit[];
ocrDocuments: OcrDocument[];
manualWeighingData: ManualWeighingRecord[];
aiRecommendations: AiRecommendation[];
ckaleData: CkaleWeightPoint[];
fcrComparisonData: FcrDataPoint[];
isLoadingData: boolean;
isRefreshingWeightKpi: boolean;
distributionSelectedDate: Date;
distributionStats: WeightDistributionStats | null;
apiMode: 'LIVE' | 'DEMO';
dailyWeightData: any[]; // DailyWeightPoint[]
dailyWeightGainData: any[]; // DailyWeightGainPoint[]
deviceWeightData: any[]; // DeviceWeightData[]
scaleDataLoading: boolean;
scaleDataLastUpdate: Date;
dailyDataLoading: boolean;
feedSackDataLastUpdate: Date | null;
isFeedSackDataLoading: boolean;
effectiveDate: Date;
isShowingPreviousDayData: boolean;
// Auth & Permissions
login: (user: User) => void;
logout: () => void;
hasPermission: (permissionId: string) => boolean;
// Handlers
onAddLocation: (name: string) => void;
onUpdateLocation: (location: Location) => void;
onDeleteLocation: (locationId: string) => void;
onAddCoop: (name: string, locationId: string) => void;
onUpdateCoop: (coop: Coop) => void;
onDeleteCoop: (coopId: string) => void;
onAddFloor: (name: string, coopId: string) => void;
onUpdateFloor: (floor: Floor) => void;
onDeleteFloor: (floorId: string) => void;
onAddCamera: (cameraData: Omit<Camera, 'id' | 'status'>) => void;
onUpdateCamera: (camera: Camera) => void;
onDeleteCamera: (cameraId: string) => void;
onAddIotDevice: (
deviceData: Omit<IotDevice, 'id' | 'status' | 'lastHeartbeat' | 'currentValue'>
) => void;
onUpdateIotDevice: (device: IotDevice) => void;
onDeleteIotDevice: (deviceId: string) => void;
onAddUser: (userData: Omit<User, 'id'>) => void;
onUpdateUser: (user: User) => void;
onDeleteUser: (userId: string) => void;
onTogglePermission: (roleId: string, permissionId: string) => void;
onUpdateVetVisit: (updatedVisit: VetVisit) => void;
onUpdateOcrDocument: (updatedDocument: OcrDocument) => void;
onAddFeedingEvent: (eventData: Omit<FeedingEvent, 'id' | 'status'>) => void;
onUpdateFeedingEvent: (event: FeedingEvent) => void;
onAddVetVisit: (visitData: Omit<VetVisit, 'id' | 'status'>) => void;
onUpdateManualWeighingRecord: (updatedRecord: ManualWeighingRecord) => void;
onUpdateRecommendationStatus: (id: string, status: RecommendationStatus) => void;
onUpdateCycleDates: (
cycleId: string,
startDate: Date,
endDate: Date,
chickInWeight?: number,
docInCount?: number
) => void;
onCreateCycle: (startDate: Date, docInCount?: number, chickInWeight?: number) => void;
setDistributionSelectedDate: (date: Date) => void;
fetchWeightDistribution: () => Promise<void>;
fetchFeedSackData: (forceRefresh?: boolean) => Promise<void>;
fetchManualFeedSackData: (startDate: string, endDate: string) => Promise<void>;
refreshFeedSackData: () => Promise<void>;
fetchScaleData: () => Promise<void>;
refreshScaleData: () => Promise<void>;
// Helpers
getFullLocationName: (floorId: string | null) => string;
getNetworkDeviceLocationName: (device: NetworkDevice) => string;
}
const AppContext = createContext<AppContextType | undefined>(undefined);
interface AppContextProviderProps {
children: ReactNode;
selectedKandangId: number | null;
}
export const AppContextProvider: React.FC<AppContextProviderProps> = ({
children,
selectedKandangId,
}) => {
// --- Custom store hooks ---
const accessManagementStore = useAccessManagementStore();
const locationStore = useLocationStore();
const deviceStore = useDeviceStore();
const staticDataStore = useStaticDataStore();
// --- Authentication State ---
const [currentUser, setCurrentUser] = useState<User | null>(null); // Default to logged out
const [isLoadingData, setIsLoadingData] = useState(false);
// --- Kandang State ---
const [kandangs, setKandangs] = useState<any[]>([]); // Store kandangs to access cage_uuid
const [isRefreshingWeightKpi, setIsRefreshingWeightKpi] = useState(false);
// --- Feed Sack Cache State ---
const [feedSackDataLastUpdate, setFeedSackDataLastUpdate] = useState<Date | null>(null);
const [isFeedSackDataLoading, setIsFeedSackDataLoading] = useState(false);
// --- D-1 Filter State (Time-Based Data Filtering) ---
const [effectiveDate, setEffectiveDate] = useState<Date>(new Date());
const [isShowingPreviousDayData, setIsShowingPreviousDayData] = useState(false);
// --- Refactored Hooks ---
const crossStoreHandlers = useCrossStoreHandlers(locationStore, deviceStore);
const derivedState = useDerivedState(deviceStore, staticDataStore, locationStore);
const locationHelpers = useLocationHelpers(locationStore);
const getCycleDayNumber = useCallback((cycleStart: Date, targetDate: Date): number => {
const start = new Date(cycleStart);
const target = new Date(targetDate);
start.setHours(0, 0, 0, 0);
target.setHours(0, 0, 0, 0);
const diffMs = target.getTime() - start.getTime();
if (diffMs < 0) {
return -1;
}
return Math.floor(diffMs / (1000 * 60 * 60 * 24)) + 1;
}, []);
type DashboardKpiRow = {
mortality?: number;
food?: number;
food_total?: number;
chicken_life?: number;
ref_weight?: number;
manual_weight?: number;
manual_fcr?: number;
iot_weight?: number;
iot_fcr?: number;
iot_eef?: number;
manual_eef?: number;
iot_average_day_weight?: number;
manual_average_day_weight?: number;
};
const getSortedKpiKeys = useCallback((apiKpiData: Record<string, DashboardKpiRow>): number[] => {
return Object.keys(apiKpiData)
.map((key) => parseInt(key, 10))
.filter((key) => Number.isFinite(key))
.sort((a, b) => a - b);
}, []);
const getLatestApiDayAge = useCallback((sortedKeys: number[]): number => {
if (sortedKeys.length === 0) {
return 0;
}
return sortedKeys[sortedKeys.length - 1];
}, []);
const resolveDashboardKpiSelection = useCallback(
(apiKpiData: Record<string, DashboardKpiRow>, cycleStart: Date, reportDate: Date) => {
const reportingDay = getCycleDayNumber(cycleStart, reportDate);
const desiredKey = Math.max(0, reportingDay - 1);
const sortedKeys = getSortedKpiKeys(apiKpiData);
const fallbackKey = sortedKeys.length > 0 ? sortedKeys[sortedKeys.length - 1] : desiredKey;
const selectedKey: number = apiKpiData[desiredKey.toString()]
? desiredKey
: (fallbackKey ?? desiredKey);
const selectedDayData = apiKpiData[selectedKey.toString()] || null;
return {
reportingDay,
desiredKey,
selectedKey,
selectedDayData,
sortedKeys,
};
},
[getCycleDayNumber, getSortedKpiKeys]
);
// Helper function to get cage UUID for selected kandang
const getCurrentCageUuid = useCallback((): string | null => {
if (selectedKandangId === null || kandangs.length === 0) return null;
const kandang = kandangs.find((k: any) => k.id === selectedKandangId);
return kandang?.cageUuid || null;
}, [selectedKandangId, kandangs]);
const activeCycle = useMemo(() => {
const active = staticDataStore.cycles.find((c) => c.status === 'Active');
if (active) {
return active;
}
// Fallback: If no active cycle, use the latest cycle (by start date)
if (staticDataStore.cycles.length === 0) {
console.error('❌ NO CYCLES FOUND! Database may be empty.');
return staticDataStore.cycles[0]; // Return undefined if no cycles
}
const latestCycle = [...staticDataStore.cycles].sort((a, b) => {
const dateA = a.startDate instanceof Date ? a.startDate : new Date(a.startDate);
const dateB = b.startDate instanceof Date ? b.startDate : new Date(b.startDate);
return dateB.getTime() - dateA.getTime(); // Sort descending (latest first)
})[0];
if (latestCycle) {
console.warn('⚠️ No active cycle found! Using latest cycle as fallback:', {
id: latestCycle.id,
startDate: latestCycle.startDate,
status: latestCycle.status,
});
}
return latestCycle;
}, [staticDataStore.cycles]);
// --- D-1 Filter: Keep effective date in sync with WIB day and cutoff ---
useEffect(() => {
const syncEffectiveDate = () => {
const shouldShowPreviousDay = shouldUsePreviousDay();
const nextEffectiveDate = getEffectiveDate();
setIsShowingPreviousDayData(shouldShowPreviousDay);
setEffectiveDate((currentEffectiveDate) => {
if (formatDateForDb(currentEffectiveDate) === formatDateForDb(nextEffectiveDate)) {
return currentEffectiveDate;
}
return nextEffectiveDate;
});
};
// Initialize on mount and keep the date aligned every minute.
syncEffectiveDate();
const interval = setInterval(() => {
syncEffectiveDate();
}, 60000); // Check every minute
return () => clearInterval(interval);
}, []);
// --- Effect to fetch data on initial load ---
useEffect(() => {
const fetchData = async () => {
setIsLoadingData(true);
// Clear alerts and mock schedules (no real-time systems implemented yet)
staticDataStore.setAlerts([]);
staticDataStore.setWeightAlerts([]);
staticDataStore.setFeedingEvents([]);
staticDataStore.setVetVisits([]);
staticDataStore.setLiveCounts([]);
try {
// 1. Load kandangs FIRST (needed to get cage UUID for weight data)
if (!activeCycle) {
console.warn('No active cycle found, skipping data fetch');
setIsLoadingData(false);
return;
}
const kandangsData = await apiClient.getKandangsByCycle(activeCycle.id);
setKandangs(kandangsData);
// 2. Fetch feed sack data (needed for saldo calculation in KPIs)
let feedSackEntries: FeedSackLogEntry[] = [];
try {
const feedSackResult = await apiClient.getFeedSackCounting(1, undefined, 'LIVE');
feedSackEntries = feedSackResult.entries;
staticDataStore.setFeedSackHistory(feedSackEntries);
staticDataStore.setAvailableFeedSackCameras(feedSackResult.availableCameras);
setFeedSackDataLastUpdate(new Date());
} catch (error) {
console.error('❌ Feed sack API error:', error);
staticDataStore.setFeedSackHistory([]);
staticDataStore.setAvailableFeedSackCameras([]);
}
// 3. Fetch weight data (using cage UUID from selected kandang)
// Get cage UUID from the kandangsData we just loaded
// Use current selectedKandangId, or fall back to first kandang if none selected
const effectiveKandangId =
selectedKandangId !== null ? selectedKandangId : kandangsData[0]?.id;
const selectedKandang = kandangsData.find((k: any) => k.id === effectiveKandangId);
const cageUuid = selectedKandang?.cageUuid || null;
const apiKpiData = await apiClient.getMultiCoopWeightData('LIVE', cageUuid);
// If no cage UUID or no data, set empty states
if (!apiKpiData) {
console.warn('No weight data available (cage UUID not configured or API error)');
staticDataStore.setMultiCoopWeightData({ target: [], coops: [] });
staticDataStore.setFcrComparisonData([]);
staticDataStore.setCkaleData([]);
staticDataStore.setWeightHistory([]);
// KPIs will be set to N/A below
} else {
const targetHistory: { day: number; weight: number }[] = [];
const manualHistory: { day: number; weight: number }[] = [];
const iotHistory: { day: number; weight: number }[] = [];
// --- Build histories from API KPI data ---
for (const dayStr in apiKpiData) {
const dayIndex = parseInt(dayStr, 10);
const day = dayIndex + 1; // convert 0-based API index to 1-based day-of-cycle
const dayData = apiKpiData[dayStr as keyof typeof apiKpiData];
if (dayData.ref_weight > 0) {
targetHistory.push({ day, weight: Math.round(dayData.ref_weight) });
}
if (dayData.manual_weight > 0) {
manualHistory.push({ day, weight: Math.round(dayData.manual_weight) });
}
if (dayData.iot_weight > 0) {
iotHistory.push({ day, weight: Math.round(dayData.iot_weight) });
}
}
// --- Update multi-coop comparison page data ---
staticDataStore.setMultiCoopWeightData({
target: targetHistory,
coops: [
{
coopId: 'coop-manual',
coopName: 'Manual (Sukawarna)',
history: manualHistory,
},
{
coopId: 'coop-iot',
coopName: 'Otomatis (Sukawarna)',
history: iotHistory,
},
],
});
// --- Build FCR comparison data ---
const manualFcrHistory: { day: number; fcr: number | null }[] = [];
const iotFcrHistory: { day: number; fcr: number | null }[] = [];
const cobbFcrHistory: { day: number; fcr: number | null }[] = [];
for (const dayStr in apiKpiData) {
const dayIndex = parseInt(dayStr, 10);
const day = dayIndex + 1; // convert 0-based API index to 1-based day
const dayData = apiKpiData[dayStr as keyof typeof apiKpiData];
// Extract manual FCR (exclude zero values as they indicate system failure)
if (
dayData.manual_fcr !== undefined &&
dayData.manual_fcr !== null &&
dayData.manual_fcr > 0
) {
manualFcrHistory.push({ day, fcr: dayData.manual_fcr });
} else {
manualFcrHistory.push({ day, fcr: null });
}
// Extract IoT FCR (exclude zero values as they indicate system failure)
if (dayData.iot_fcr !== undefined && dayData.iot_fcr !== null && dayData.iot_fcr > 0) {
iotFcrHistory.push({ day, fcr: dayData.iot_fcr });
} else {
iotFcrHistory.push({ day, fcr: null });
}
// Get COBB standard FCR for this day
const cobbStandard = cobbStandardPerformance.find((std) => std.day === day);
if (cobbStandard) {
cobbFcrHistory.push({ day, fcr: cobbStandard.fcr });
} else {
cobbFcrHistory.push({ day, fcr: null });
}
}
// Combine into single data structure for chart
const fcrComparisonData = [];
const maxDay = Math.max(
manualFcrHistory.length,
iotFcrHistory.length,
cobbFcrHistory.length
);
for (let i = 0; i < maxDay; i++) {
const day = i + 1;
fcrComparisonData.push({
day,
manualFcr: manualFcrHistory[i]?.fcr ?? null,
iotFcr: iotFcrHistory[i]?.fcr ?? null,
cobbFcr: cobbFcrHistory[i]?.fcr ?? null,
});
}
// Update FCR comparison data in store
staticDataStore.setFcrComparisonData(fcrComparisonData);
// --- Build CKALE data for Combined Weight Trend Chart ---
// This combines manual and IoT weight data from the API
const ckaleDataPoints: CkaleWeightPoint[] = [];
for (const dayStr in apiKpiData) {
const dayIndex = parseInt(dayStr, 10);
// Skip day 0 (DOC weight) - start from day 1
if (dayIndex === 0) continue;
const day = dayIndex + 1; // convert 0-based API index to 1-based day
const dayData = apiKpiData[dayStr as keyof typeof apiKpiData];
ckaleDataPoints.push({
day,
manual: dayData.manual_weight > 0 ? Math.round(dayData.manual_weight) : null,
all: dayData.iot_weight > 0 ? Math.round(dayData.iot_weight) : null,
front: null, // Not available from API
back: null, // Not available from API
});
}
staticDataStore.setCkaleData(ckaleDataPoints);
// --- Also feed Dashboard Berat (weight monitoring) from the same API ---
// Prefer IoT weights for dashboard; fall back to manual if IoT not available.
const primaryHistorySource = iotHistory.length > 0 ? iotHistory : manualHistory;
if (primaryHistorySource.length > 0) {
// Sort by day just in case
const sortedPrimary = [...primaryHistorySource].sort((a, b) => a.day - b.day);
// Map to WeightDataPoint[] used by Dashboard Berat trend chart
const weightHistoryData = sortedPrimary.map((point) => {
const apiEntry = Object.entries(apiKpiData)
.map(([key, value]) => ({ key: parseInt(key, 10) + 1, value }))
.find((entry) => entry.key === point.day)?.value as
| { ref_weight: number }
| undefined;
return {
day: point.day,
actual: point.weight,
target: apiEntry ? Math.round(apiEntry.ref_weight) : null,
};
});
staticDataStore.setWeightHistory(weightHistoryData);
}
} // End of apiKpiData check
// --- Mortality data will come from the KPI API (kandang-specific) ---
// --- Extract Dashboard KPIs from API data ---
const { selectedDayData, sortedKeys } = resolveDashboardKpiSelection(
apiKpiData,
new Date(activeCycle.startDate),
effectiveDate
);
const formatSparklineDate = (date: Date): string =>
date
.toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
timeZone: 'Asia/Jakarta',
})
.replace(',', '');
const buildHistory = (
selector: (row: DashboardKpiRow) => number | undefined,
includeZeroValues = false
): { values: number[]; dates: string[] } => {
const history: number[] = [];
const dates: string[] = [];
for (const dayIndex of sortedKeys) {
const row = apiKpiData[dayIndex.toString()];
const value = selector(row);
if (typeof value === 'number' && (includeZeroValues ? value >= 0 : value > 0)) {
history.push(value);
const pointDate = new Date(activeCycle.startDate);
pointDate.setDate(pointDate.getDate() + dayIndex);
dates.push(formatSparklineDate(pointDate));
}
}
return { values: history, dates };
};
const mortalityHistory = buildHistory((row) => row.mortality, true);
const weightHistoryValues = buildHistory((row) => row.iot_weight || row.manual_weight);
const fcrHistoryValues = buildHistory((row) => row.iot_fcr || row.manual_fcr);
const eefHistory = buildHistory((row) => row.iot_eef || row.manual_eef);
const foodHistoryValues = buildHistory((row) => row.food, true);
// Determine statuses once we know the selected KPI row
const getWeightStatus = (
actualWeight: number,
refWeight: number
): 'ok' | 'warning' | 'critical' => {
const deviation = actualWeight - refWeight;
const deviationPercent = (deviation / refWeight) * 100;
if (Math.abs(deviationPercent) <= 5) return 'ok';
if (Math.abs(deviationPercent) <= 10) return 'warning';
return 'critical';
};
const getFcrStatus = (fcr: number): 'ok' | 'warning' | 'critical' => {
if (fcr <= 1.6) return 'ok';
if (fcr <= 1.8) return 'warning';
return 'critical';
};
const getEefStatus = (eef: number): 'ok' | 'warning' | 'critical' => {
if (eef >= 350) return 'ok';
if (eef >= 300) return 'warning';
return 'critical';
};
const getMortalityStatus = (count: number): 'ok' | 'warning' | 'critical' => {
if (count < 10) return 'ok';
if (count <= 30) return 'warning';
return 'critical';
};
const umurValue = getLatestApiDayAge(sortedKeys);
const latestWeight = selectedDayData?.iot_weight ?? selectedDayData?.manual_weight ?? 0;
const latestMortalityCount = selectedDayData?.mortality ?? 0;
const fcrValue = selectedDayData?.iot_fcr ?? selectedDayData?.manual_fcr ?? 0;
const eefValue = selectedDayData?.iot_eef ?? selectedDayData?.manual_eef ?? 0;
const karungDituangToday = selectedDayData?.food ?? 0;
const newKpis = {
umur: {
title: 'Umur',
value: umurValue,
unit: 'hari',
icon: '🐣',
trend: [],
status: 'ok' as const,
},
mortality: {
title: 'Mortalitas',
value: latestMortalityCount,
unit: latestMortalityCount > 0 ? 'ekor' : 'N/A',
icon: '⚠️',
trend: mortalityHistory.values,
trendDates: mortalityHistory.dates,
status:
latestMortalityCount > 0 ? getMortalityStatus(latestMortalityCount) : ('ok' as const),
targetView: 'dataManagement' as const,
},
weight: {
title: 'BW',
value: latestWeight > 0 ? latestWeight : 0,
unit: latestWeight > 0 ? 'gram' : 'N/A',
icon: '⚖️',
trend: weightHistoryValues.values,
trendDates: weightHistoryValues.dates,
status:
latestWeight > 0 && selectedDayData?.ref_weight
? getWeightStatus(latestWeight, selectedDayData.ref_weight)
: ('ok' as const),
targetView: 'weightMonitoring' as const,
},
fcr: {
title: 'FCR',
value: fcrValue > 0 ? fcrValue : 0,
unit: fcrValue > 0 ? '' : 'N/A',
icon: '📊',
trend: fcrHistoryValues.values,
trendDates: fcrHistoryValues.dates,
status: fcrValue > 0 ? getFcrStatus(fcrValue) : ('ok' as const),
targetView: 'fcrDashboard' as const,
},
eef: {
title: 'EEF',
value: eefValue > 0 ? eefValue : 0,
unit: eefValue > 0 ? '' : 'N/A',
icon: '📈',
trend: eefHistory.values,
trendDates: eefHistory.dates,
status: eefValue > 0 ? getEefStatus(eefValue) : ('ok' as const),
targetView: 'eefDashboard' as const,
},
karungDituang: {
title: 'Karung Dituang',
value: karungDituangToday,
unit: 'karung',
icon: '🛢️',
trend: foodHistoryValues.values,
trendDates: foodHistoryValues.dates,
status: 'ok' as const,
targetView: 'feedSackCounting' as const,
},
};
staticDataStore.setKpis(newKpis);
} catch (error) {
console.error('❌ Error processing multi-coop weight data:', error);
console.error('❌ Error stack:', error instanceof Error ? error.stack : error);
staticDataStore.setMultiCoopWeightData({ target: [], coops: [] });
// Show N/A for all KPIs on error with neutral status
staticDataStore.setKpis({
umur: {
title: 'Umur',
value: 0,
unit: 'N/A',
icon: '🐣',
trend: [],
status: 'ok' as const,
},
mortality: {
title: 'Mortalitas',
value: 0,
unit: 'N/A',
icon: '⚠️',
trend: [],
status: 'ok' as const,
},
weight: {
title: 'BW',
value: 0,
unit: 'N/A',
icon: '⚖️',
trend: [],
status: 'ok' as const,
},
fcr: {
title: 'FCR',
value: 0,
unit: 'N/A',
icon: '📊',
trend: [],
status: 'ok' as const,
targetView: 'fcrDashboard' as const,
},
eef: {
title: 'EEF',
value: 0,
unit: 'N/A',
icon: '📈',
trend: [],
status: 'ok' as const,
targetView: 'eefDashboard' as const,
},
karungDituang: {
title: 'Karung Dituang',
value: 0,
unit: 'N/A',
icon: '🛢️',
trend: [],
status: 'ok' as const,
},
});
}
// 3. Fetch location data
try {
const locationData = await apiClient.getCageSites('LIVE');
locationStore.initializeFromApi(locationData);
} catch (error) {
console.error('Cage sites API error:', error);
}
// 4. Fetch IoT sensor data for the current day using range API
try {
// Get current date in WIB timezone (API interprets dates in Jakarta timezone)
const wibDateStr = new Date()
.toLocaleString('en-CA', {
timeZone: 'Asia/Jakarta',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.split(',')[0]; // Format: YYYY-MM-DD
// Query current day in WIB timezone
const iotRangeData = await apiClient.getIotSensorRange(wibDateStr, wibDateStr, 'LIVE');
// Replace environment data with API data
staticDataStore.setEnvironmentData(iotRangeData);
} catch (error) {
console.error('IoT sensor range fetch error:', error);
}
};
fetchData().finally(() => {
// Set loading to false after all API calls complete
setTimeout(() => setIsLoadingData(false), 500); // Small delay for smooth transition
});
}, [
staticDataStore.setMultiCoopWeightData,
staticDataStore.setInitialWeight,
staticDataStore.setWeightHistory,
staticDataStore.setFeedSackHistory,
staticDataStore.setEnvironmentData,
locationStore.initializeFromApi,
effectiveDate,
]);
// --- IoT Polling Effect (60 seconds) ---
// Refetch range data periodically to keep the graph updated with latest readings
useEffect(() => {
const fetchIotRangeData = async () => {
try {
// Get current date in WIB timezone (API interprets dates in Jakarta timezone)
const wibDateStr = new Date()
.toLocaleString('en-CA', {
timeZone: 'Asia/Jakarta',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.split(',')[0]; // Format: YYYY-MM-DD
// Query current day in WIB timezone
const iotRangeData = await apiClient.getIotSensorRange(wibDateStr, wibDateStr, 'LIVE');
// Update environment data with fresh API data
staticDataStore.setEnvironmentData(iotRangeData);
} catch (error) {
console.error('IoT polling error:', error);
}
};
// Poll every 60 seconds to get latest data
const pollInterval = setInterval(fetchIotRangeData, 60000);
return () => clearInterval(pollInterval);
}, [staticDataStore.setEnvironmentData, staticDataStore.setKpis]);
// --- Weight Distribution Fetcher ---
const fetchWeightDistribution = useCallback(async () => {
// Guard: Check if activeCycle and startDate exist
if (!activeCycle || !activeCycle.startDate) {
console.warn('fetchWeightDistribution: activeCycle or startDate not available');
return;
}
try {
// Use the calendar-selected date as-is (no D-1 override for explicit user picks)
const dateStr = formatDateForDb(staticDataStore.distributionSelectedDate);
const cycleStartStr =
activeCycle.startDate instanceof Date
? activeCycle.startDate.toISOString().split('T')[0]
: activeCycle.startDate;
const response: WeightDistributionApiResponse = await apiClient.getWeightDistributionRange(
dateStr,
dateStr,
'LIVE',
cycleStartStr
);
// Process response
if (response.success) {
// Get data (could be array with single element or single object)
const apiData = Array.isArray(response.data) ? response.data[0] : response.data;
// CRITICAL: Handle empty data as per user requirement
if (!apiData || !apiData.distribusi || apiData.distribusi.length === 0) {
staticDataStore.setWeightDistribution([]);
staticDataStore.setDistributionStats(null);
return;
}
// Transform API data to chart format
const chartData: WeightDistributionPoint[] = apiData.distribusi.map((item) => ({
range: item.range_bobot,
count: item.total_ayam,
}));
// Calculate day of cycle
const dataDate = new Date(apiData.tanggal);
const cycleStart = new Date(activeCycle.startDate);
dataDate.setHours(0, 0, 0, 0);
cycleStart.setHours(0, 0, 0, 0);
const dayOfCycle =
Math.floor((dataDate.getTime() - cycleStart.getTime()) / (1000 * 60 * 60 * 24)) + 1;
// Store statistics
const stats: WeightDistributionStats = {
date: new Date(apiData.tanggal).toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}),
dayOfCycle,
totalPredictions: apiData.total_predictions,
totalChickenCount: apiData.total_chicken_count,
min: apiData.statistik.min,
max: apiData.statistik.max,
avg: apiData.statistik.avg,
standardDeviation: apiData.statistik.Standard_deviasi,
uniformity: apiData.statistik.uniformity,
};
staticDataStore.setWeightDistribution(chartData);
staticDataStore.setDistributionStats(stats);
}
} catch (error) {
console.error('Error fetching weight distribution:', error);
// Keep existing data on error
}
}, [staticDataStore.distributionSelectedDate, activeCycle.startDate, staticDataStore]);
// --- Sensor Weight Summary Fetcher (also fetches device weight data for selected date) ---
const fetchSensorWeightSummary = useCallback(async () => {
staticDataStore.setDailyDataLoading(true);
try {
const dateStr = formatDateForDb(staticDataStore.distributionSelectedDate);
const response = await apiClient.getSensorWeightSummary(dateStr, 'LIVE');
if (response.success) {
staticDataStore.setSensorWeightSummary(response.data);
// Also fetch cage info and process device weight data for the comparison chart
const cageUuid = getCurrentCageUuid();
// Skip cage data if no UUID configured
if (cageUuid) {
const cageUrl = `https://dashboard.cpsp.id/api/scales/cage/${cageUuid}`;
const cageResponse = await fetch(cageUrl);
const cageData = await cageResponse.json();
// Process device weight data for the selected date
const deviceWeight = processDeviceWeightData(response, cageData.data?.devices || []);
staticDataStore.setDeviceWeightData(deviceWeight);
} else {
console.warn('No cage UUID configured, skipping device weight data');
staticDataStore.setDeviceWeightData([]);
}
}
} catch (error) {
console.error('Error fetching sensor weight summary:', error);
staticDataStore.setSensorWeightSummary([]);
staticDataStore.setDeviceWeightData([]);
} finally {
staticDataStore.setDailyDataLoading(false);
}
}, [staticDataStore.distributionSelectedDate, staticDataStore]);
// --- Scale Data Fetcher (for revamped weight dashboard) ---
const fetchScaleData = useCallback(async () => {
if (!activeCycle || !activeCycle.startDate) {
console.warn('fetchScaleData: activeCycle or startDate not available');
return;
}
staticDataStore.setScaleDataLoading(true);
try {
const cycleStart =
activeCycle.startDate instanceof Date
? activeCycle.startDate
: new Date(activeCycle.startDate);
const cycleStartStr = cycleStart.toISOString().split('T')[0];
const statsDate = new Date(effectiveDate);
statsDate.setHours(0, 0, 0, 0);
const previousStatsDate = new Date(statsDate);
previousStatsDate.setDate(previousStatsDate.getDate() - 1);
const dayBeforePreviousStatsDate = new Date(previousStatsDate);
dayBeforePreviousStatsDate.setDate(dayBeforePreviousStatsDate.getDate() - 1);
const statsDateStr = formatDateForDb(statsDate);
const previousStatsDateStr = formatDateForDb(previousStatsDate);
const dayBeforePreviousStatsDateStr = formatDateForDb(dayBeforePreviousStatsDate);
const displayDate = formatEffectiveDateLabel(statsDate);
// Fetch scale predictions range for entire cycle
const rangeResponse = await apiClient.getScalePredictionsRange(
cycleStartStr,
statsDateStr,
'LIVE'
);
// Fetch effective day and prior distributions to keep stats aligned with D-1 logic
const currentDistribution = await apiClient.getWeightDistributionRange(
statsDateStr,
statsDateStr,
'LIVE',
cycleStartStr
);
const previousDistribution = await apiClient.getWeightDistributionRange(
previousStatsDateStr,
previousStatsDateStr,
'LIVE',
cycleStartStr
);
const dayBeforePreviousDistribution = await apiClient.getWeightDistributionRange(
dayBeforePreviousStatsDateStr,
dayBeforePreviousStatsDateStr,
'LIVE',
cycleStartStr
);
if (rangeResponse.success && currentDistribution.success) {
// Process daily weight data
const dailyWeight = processDailyWeightData(
rangeResponse,
cycleStart,
activeCycle.totalDays
);
staticDataStore.setDailyWeightData(dailyWeight);
// Calculate daily weight gain
const dailyGain = calculateDailyWeightGain(dailyWeight);
staticDataStore.setDailyWeightGainData(dailyGain);
// Use the effective date first, then fallback to the previous day if needed
let selectedDistData = Array.isArray(currentDistribution.data)
? currentDistribution.data[0]
: currentDistribution.data;
let selectedDateLabel = displayDate;
let comparisonDistData = Array.isArray(previousDistribution.data)
? previousDistribution.data[0]
: previousDistribution.data;
if (!selectedDistData || selectedDistData.total_chicken_count === 0) {
if (previousDistribution.success && comparisonDistData?.total_chicken_count > 0) {
selectedDistData = comparisonDistData;
selectedDateLabel = formatEffectiveDateLabel(previousStatsDate);
comparisonDistData = Array.isArray(dayBeforePreviousDistribution.data)
? dayBeforePreviousDistribution.data[0]
: dayBeforePreviousDistribution.data;
}
}
// If still no data available (both today and yesterday empty)
if (!selectedDistData || selectedDistData.total_chicken_count === 0) {
// Set empty/null stats to indicate no data
staticDataStore.setInitialWeight({
averageWeight: {
value: 0,
unit: 'N/A',
status: 'critical',
},
avgDailyGain: {
value: 0,
unit: 'N/A',
status: 'critical',
},
uniformity: {
value: 0,
unit: 'N/A',
status: 'critical',
},
dataDate: displayDate,
});
} else {
// Get average weight from distribusi bobot API
const avgWeight = selectedDistData.statistik.avg || 0;
// Calculate daily gain from previous day's distribusi bobot API
let dailyGain = 0;
const priorAvg = comparisonDistData?.statistik?.avg || 0;
if (priorAvg > 0) {
dailyGain = avgWeight - priorAvg;
}
// Get uniformity from distribusi bobot API
const uniformity = selectedDistData.statistik.uniformity || 0;
// Status helper functions
const getAdgStatus = (adg: number): 'ok' | 'warning' | 'critical' => {
if (adg >= 60) return 'ok';
if (adg >= 40) return 'warning';
return 'critical';
};
const getUniformityStatus = (uniformity: number): 'ok' | 'warning' | 'critical' => {
if (uniformity >= 85) return 'ok';
if (uniformity >= 75) return 'warning';
return 'critical';
};
// Set stats from distribusi bobot API
// Keep precision: avgWeight with 2 decimals, uniformity with 1 decimal (no rounding yet for display)
const stats = {
averageWeight: {
value: avgWeight, // Keep full precision, will format in display
unit: 'g',
status: 'ok' as const, // Will be updated with actual target comparison if needed
},
avgDailyGain: {
value: Math.round(dailyGain), // Round ADG to whole number
unit: 'g/hari',
status: getAdgStatus(dailyGain),
},
uniformity: {
value: uniformity, // Keep full precision, will format in display
unit: '%',
status: getUniformityStatus(uniformity),
},
dataDate: selectedDateLabel, // Add the date label
};
staticDataStore.setInitialWeight(stats);
}
staticDataStore.setScaleDataLastUpdate(new Date());
}
} catch (error) {
console.error('❌ Error fetching scale data:', error);
// Keep existing data on error
} finally {
staticDataStore.setScaleDataLoading(false);
}
}, [activeCycle, effectiveDate]); // Re-run when the active cycle or effective date changes
// Refresh function (clears cache and refetches)
const refreshScaleData = useCallback(async () => {
await fetchScaleData();
}, [fetchScaleData]);
// --- Auto-refresh scale data every 30 minutes ---
useEffect(() => {
// Only run if activeCycle is available
if (!activeCycle || !activeCycle.startDate) {
return;
}
// Initial fetch
fetchScaleData();
// Set up 30-minute interval
const interval = setInterval(
() => {
fetchScaleData();
},
30 * 60 * 1000
); // 30 minutes
return () => {
clearInterval(interval);
};
}, [activeCycle, fetchScaleData]); // Re-run when activeCycle or fetchScaleData changes
// --- Effect to fetch weight distribution when date changes ---
useEffect(() => {
// Auto-fetch when selected date changes or when activeCycle becomes available
fetchWeightDistribution();
fetchSensorWeightSummary();
}, [staticDataStore.distributionSelectedDate, activeCycle]); // eslint-disable-line react-hooks/exhaustive-deps
// --- Effect to reload weight data when kandang selection changes ---
useEffect(() => {
const abortController = new AbortController();
// Only reload if we have an active cycle and kandang data is available
if (activeCycle && kandangs.length > 0 && selectedKandangId !== null) {
// Refetch weight KPI data with new cage UUID
const refetchWeightKpiData = async () => {
setIsRefreshingWeightKpi(true);
try {
const selectedKandang = kandangs.find((k: any) => k.id === selectedKandangId);
const cageUuid = selectedKandang?.cageUuid || null;
const apiKpiData = await apiClient.getMultiCoopWeightData('LIVE', cageUuid);
if (!apiKpiData) {
console.warn('No weight data available (cage UUID not configured or API error)');
staticDataStore.setMultiCoopWeightData({ target: [], coops: [] });
staticDataStore.setFcrComparisonData([]);
staticDataStore.setCkaleData([]);
staticDataStore.setWeightHistory([]);
staticDataStore.setInitialWeight({
averageWeight: { value: 0, unit: 'N/A', status: 'ok' },
avgDailyGain: { value: 0, unit: 'N/A', status: 'ok' },
uniformity: { value: 0, unit: 'N/A', status: 'ok' },
dataDate: formatEffectiveDateLabel(effectiveDate),
});
// Update KPIs to show N/A for weight-related metrics
staticDataStore.setKpis((currentKpis) => ({
...currentKpis,
umur: {
title: 'Umur',
value: 0,
unit: 'N/A',
icon: '🐣',
trend: [],
status: 'ok',
},
mortality: {
title: 'Mortalitas',
value: 0,
unit: 'N/A',
icon: '⚠️',
trend: [],
status: 'ok',
targetView: 'dataManagement',
},
weight: {
title: 'BW',
value: 0,
unit: 'N/A',
icon: '⚖️',
trend: [],
status: 'ok',
targetView: 'weightMonitoring',
},
fcr: {
title: 'FCR',
value: 0,
unit: 'N/A',
icon: '📊',
trend: [],
status: 'ok',
targetView: 'multiCoopWeight',
},
eef: {
title: 'EEF',
value: 0,
unit: 'N/A',
icon: '📈',
trend: [],
status: 'ok',
},
karungDituang: {
title: 'Karung Dituang',
value: 0,
unit: 'N/A',
icon: '🛢️',
trend: [],
status: 'ok',
targetView: 'feedSackCounting',
},
}));
} else {
// Rebuild weight data stores from API (same logic as initial load)
const targetHistory: { day: number; weight: number }[] = [];
const manualHistory: { day: number; weight: number }[] = [];
const iotHistory: { day: number; weight: number }[] = [];
for (const dayStr in apiKpiData) {
const dayIndex = parseInt(dayStr, 10);
const day = dayIndex + 1;
const dayData = apiKpiData[dayStr as keyof typeof apiKpiData];
if (dayData.ref_weight > 0) {
targetHistory.push({ day, weight: Math.round(dayData.ref_weight) });
}
if (dayData.manual_weight > 0) {
manualHistory.push({ day, weight: Math.round(dayData.manual_weight) });
}
if (dayData.iot_weight > 0) {
iotHistory.push({ day, weight: Math.round(dayData.iot_weight) });
}
}
staticDataStore.setMultiCoopWeightData({
target: targetHistory,
coops: [
{ coopId: 'coop-manual', coopName: 'Manual (Sukawarna)', history: manualHistory },
{ coopId: 'coop-iot', coopName: 'Otomatis (Sukawarna)', history: iotHistory },
],
});
// Weight KPI cards/history only; weightStats comes from fetchScaleData (distribusi bobot API).
const primaryHistorySource = iotHistory.length > 0 ? iotHistory : manualHistory;
if (primaryHistorySource.length > 0) {
const sortedPrimary = [...primaryHistorySource].sort((a, b) => a.day - b.day);
const effectiveDay = getCycleDayNumber(activeCycle.startDate, effectiveDate);
const selectedDay =
effectiveDay > 0 && sortedPrimary.some((point) => point.day === effectiveDay)
? effectiveDay
: sortedPrimary[sortedPrimary.length - 1].day;
const last =
sortedPrimary.find((point) => point.day === selectedDay) ||
sortedPrimary[sortedPrimary.length - 1];
if (!last) {
console.warn('Insufficient weight data for stats calculation');
return;
}
const apiEntryForLastDay = Object.entries(apiKpiData)
.map(([key, value]) => ({ key: parseInt(key, 10) + 1, value }))
.find((entry) => entry.key === last.day)?.value as
| { ref_weight: number; manual_weight: number; iot_weight: number }
| undefined;
const refWeight = apiEntryForLastDay?.ref_weight ?? last.weight;
const weightHistoryData = sortedPrimary.map((point) => {
const apiEntry = Object.entries(apiKpiData)
.map(([key, value]) => ({ key: parseInt(key, 10) + 1, value }))
.find((entry) => entry.key === point.day)?.value as
| { ref_weight: number }
| undefined;
return {
day: point.day,
actual: point.weight,
target: apiEntry ? Math.round(apiEntry.ref_weight) : null,
};
});
staticDataStore.setWeightHistory(weightHistoryData);
// Update the weight-related KPIs without clobbering feed-sack values
const getWeightStatus = (
actualWeight: number,
refWeight: number
): 'ok' | 'warning' | 'critical' => {
const deviation = actualWeight - refWeight;
const deviationPercent = (deviation / refWeight) * 100;
if (Math.abs(deviationPercent) <= 5) return 'ok';
if (Math.abs(deviationPercent) <= 10) return 'warning';
return 'critical';
};
const getFcrStatus = (fcr: number): 'ok' | 'warning' | 'critical' => {
if (fcr <= 1.6) return 'ok';
if (fcr <= 1.8) return 'warning';
return 'critical';
};
const getEefStatus = (eef: number): 'ok' | 'warning' | 'critical' => {
if (eef >= 350) return 'ok';
if (eef >= 300) return 'warning';
return 'critical';
};
const { selectedDayData } = resolveDashboardKpiSelection(
apiKpiData,
new Date(activeCycle.startDate),
effectiveDate
);
// Build FCR history
const fcrHistory: number[] = [];
const sortedKeys = Object.keys(apiKpiData)
.map((k) => parseInt(k, 10))
.sort((a, b) => a - b);
const foodHistory: number[] = [];
for (const dayIndex of sortedKeys) {
const dayData = apiKpiData[dayIndex.toString()];
foodHistory.push(dayData.food || 0);
const fcrValue = dayData.iot_fcr || dayData.manual_fcr;
if (fcrValue > 0) {
fcrHistory.push(fcrValue);
}
}
// Build EEF history
const eefHistory: number[] = [];
for (const dayIndex of sortedKeys) {
const dayData = apiKpiData[dayIndex.toString()];
const eefValue = dayData.iot_eef || dayData.manual_eef;
if (eefValue > 0) {
eefHistory.push(eefValue);
}
}
const selectedWeight =
selectedDayData?.iot_weight ?? selectedDayData?.manual_weight ?? last.weight;
const selectedFcr = selectedDayData?.iot_fcr ?? selectedDayData?.manual_fcr ?? 0;
const selectedEef = selectedDayData?.iot_eef ?? selectedDayData?.manual_eef ?? 0;
const selectedKarung = selectedDayData?.food ?? 0;
const latestApiDayAge = getLatestApiDayAge(sortedKeys);
staticDataStore.setKpis((currentKpis) => ({
...currentKpis,
umur: {
title: 'Umur',
value: latestApiDayAge,
unit: 'hari',
icon: '🐣',
trend: [],
status: 'ok',
},
weight: {
title: 'BW',
value: selectedWeight,
unit: 'gram',
icon: '⚖️',
trend: sortedPrimary.map((p) => p.weight),
status: getWeightStatus(selectedWeight, selectedDayData?.ref_weight ?? refWeight),
targetView: 'weightMonitoring',
},
fcr: {
title: 'FCR',
value: selectedFcr > 0 ? selectedFcr : 0,
unit: selectedFcr > 0 ? '' : 'N/A',
icon: '📊',
trend: fcrHistory,
status: selectedFcr > 0 ? getFcrStatus(selectedFcr) : 'critical',
targetView: 'multiCoopWeight',
},
eef: {
title: 'EEF',
value: selectedEef > 0 ? selectedEef : 0,
unit: selectedEef > 0 ? '' : 'N/A',
icon: '📈',
trend: eefHistory,
status: selectedEef > 0 ? getEefStatus(selectedEef) : 'critical',
},
karungDituang: {
title: 'Karung Dituang',
value: selectedKarung,
unit: 'karung',
icon: '🛢️',
trend: foodHistory,
status: 'ok',
targetView: 'feedSackCounting',
},
}));
}
}
} catch (error) {
console.error('Error refetching weight KPI data:', error);
} finally {
setIsRefreshingWeightKpi(false);
}
};
// Run refetch and wait for it to complete before clearing loading state
refetchWeightKpiData().then(() => {
// Also reload weight-related data for other pages (don't wait for these)
fetchScaleData();
fetchSensorWeightSummary();
});
}
return () => {
abortController.abort();
};
}, [selectedKandangId, kandangs.length]); // eslint-disable-line react-hooks/exhaustive-deps
// --- Permission Checker ---
const hasPermission = useCallback(
(permissionId: string): boolean => {
if (!currentUser) return false;
if (currentUser.roleId === 'role-1') return true; // Admin has all permissions
return accessManagementStore.rolePermissions.some(
(rp) => rp.roleId === currentUser.roleId && rp.permissionId === permissionId
);
},
[currentUser, accessManagementStore.rolePermissions]
);
// --- Authentication Handlers ---
const login = useCallback(
(user: User) => {
const adminUser = accessManagementStore.users.find((u) => u.roleId === 'role-1');
if (adminUser) {
setCurrentUser(adminUser);
} else {
setCurrentUser(user);
}
},
[accessManagementStore.users]
);
const logout = useCallback(() => setCurrentUser(null), []);
// --- Feed Sack Data Fetcher (on-demand with caching) ---
const fetchFeedSackData = useCallback(
async (forceRefresh = false) => {
try {
// Check cache validity (30 minutes = 1800000 ms)
const now = new Date();
const cacheAge = feedSackDataLastUpdate
? now.getTime() - feedSackDataLastUpdate.getTime()
: Infinity;
const isCacheValid = cacheAge < 30 * 60 * 1000; // 30 minutes
// Use cache if valid and not forcing refresh
if (!forceRefresh && isCacheValid && staticDataStore.feedSackHistory.length > 0) {
return;
}
setIsFeedSackDataLoading(true);
const result = await apiClient.getFeedSackCounting(1, undefined, 'LIVE');
staticDataStore.setFeedSackHistory(result.entries);
staticDataStore.setAvailableFeedSackCameras(result.availableCameras);
setFeedSackDataLastUpdate(new Date());
} catch (error) {
console.error('❌ Feed sack API error:', error);
staticDataStore.setFeedSackHistory([]);
staticDataStore.setAvailableFeedSackCameras([]);
} finally {
setIsFeedSackDataLoading(false);
}
},
[
feedSackDataLastUpdate,
staticDataStore.setFeedSackHistory,
staticDataStore.setAvailableFeedSackCameras,
staticDataStore.feedSackHistory.length,
setIsFeedSackDataLoading,
]
);
// --- Manual Refresh for Feed Sack Data ---
const refreshFeedSackData = useCallback(async () => {
await fetchFeedSackData(true); // Force refresh
}, [fetchFeedSackData]);
// --- Assemble final context value ---
const value: AppContextType = {
currentUser,
login,
logout,
hasPermission,
isLoadingData,
isRefreshingWeightKpi,
...accessManagementStore,
...locationStore,
...deviceStore,
...staticDataStore,
...crossStoreHandlers,
...derivedState,
...locationHelpers,
cycleData: activeCycle,
// Explicitly include device add/update from deviceStore to satisfy the type
onAddCamera: deviceStore.onAddCamera,
onUpdateCamera: deviceStore.onUpdateCamera,
onDeleteCamera: deviceStore.onDeleteCamera,
onAddIotDevice: deviceStore.onAddIotDevice,
onUpdateIotDevice: deviceStore.onUpdateIotDevice,
onDeleteIotDevice: deviceStore.onDeleteIotDevice,
// Explicitly include location add/update from locationStore
onAddLocation: locationStore.onAddLocation,
onUpdateLocation: locationStore.onUpdateLocation,
onAddCoop: locationStore.onAddCoop,
onUpdateCoop: locationStore.onUpdateCoop,
onAddFloor: locationStore.onAddFloor,
onUpdateFloor: locationStore.onUpdateFloor,
// Weight stats rename
weightStats: staticDataStore.initialWeight,
// Weight distribution date selection
distributionSelectedDate: staticDataStore.distributionSelectedDate,
distributionStats: staticDataStore.distributionStats,
setDistributionSelectedDate: staticDataStore.setDistributionSelectedDate,
fetchWeightDistribution,
fetchFeedSackData,
fetchManualFeedSackData: staticDataStore.fetchManualFeedSackData,
// Feed sack cache state and refresh
feedSackDataLastUpdate,
isFeedSackDataLoading,
refreshFeedSackData,
// New scale data for revamped dashboard
dailyWeightData: staticDataStore.dailyWeightData,
dailyWeightGainData: staticDataStore.dailyWeightGainData,
deviceWeightData: staticDataStore.deviceWeightData,
scaleDataLoading: staticDataStore.scaleDataLoading,
scaleDataLastUpdate: staticDataStore.scaleDataLastUpdate,
dailyDataLoading: staticDataStore.dailyDataLoading,
fetchScaleData,
refreshScaleData,
// API mode - always LIVE since we're using real APIs
apiMode: 'LIVE',
// D-1 filter state
effectiveDate,
isShowingPreviousDayData,
};
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
};
export const useAppContext = (): AppContextType => {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error('useAppContext must be used within an AppContextProvider');
}
return context;
};