1608 lines
58 KiB
TypeScript
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;
|
|
};
|