253 lines
7.9 KiB
TypeScript
253 lines
7.9 KiB
TypeScript
import React, { useLayoutEffect, useMemo, useRef, useState, useEffect } from 'react';
|
|
import KpiCard from './KpiCard.tsx';
|
|
import ChickenCountingDashboard from './counting/ChickenCountingDashboard.tsx';
|
|
import DailyWeightGainChart from './weight/DailyWeightGainChart.tsx';
|
|
import { AiInsightCard } from './AiInsightCard.tsx';
|
|
import FeedSaldoCard from './feedSack/FeedSaldoCard.tsx';
|
|
import KarungDituangTodayCard from './feedSack/KarungDituangTodayCard.tsx';
|
|
import { Kpi } from '../types.ts';
|
|
import { View } from '../types/common.ts';
|
|
import {
|
|
Kandang,
|
|
FeedSackColumnConfig,
|
|
DEFAULT_FEED_SACK_COLUMNS,
|
|
CycleFeedInitialBalance,
|
|
} from '../types/counting.ts';
|
|
import { performanceService } from '../services/performanceService.ts';
|
|
import { useAppContext } from '../context/AppContext.tsx';
|
|
import { apiClient } from '../services/apiService.ts';
|
|
import {
|
|
getUsageCameraNamesForKandang,
|
|
normalizeFeedSackColumnConfigs,
|
|
} from '../utils/feedSackMapping.ts';
|
|
|
|
interface DashboardProps {
|
|
kpis: Record<string, Kpi>;
|
|
setView: (view: View) => void;
|
|
selectedArea: string;
|
|
onAreaChange: (area: string) => void;
|
|
selectedKandangId: number | null;
|
|
onKandangChange: (id: number | null) => void;
|
|
kandangs: Kandang[];
|
|
}
|
|
|
|
const Dashboard: React.FC<DashboardProps> = ({
|
|
kpis,
|
|
setView,
|
|
selectedArea: _selectedArea,
|
|
onAreaChange: _onAreaChange,
|
|
selectedKandangId,
|
|
onKandangChange,
|
|
kandangs,
|
|
}) => {
|
|
const mountStart = useRef(performance.now());
|
|
const selectedKandangName = useMemo(
|
|
() =>
|
|
selectedKandangId ? kandangs.find((k) => k.id === selectedKandangId)?.name || null : null,
|
|
[kandangs, selectedKandangId]
|
|
);
|
|
const {
|
|
dailyWeightGainData,
|
|
cycleData,
|
|
isRefreshingWeightKpi,
|
|
feedSackHistory,
|
|
effectiveDate,
|
|
isShowingPreviousDayData,
|
|
} = useAppContext();
|
|
|
|
// Column config state for FeedSaldoCard (loaded from DB)
|
|
const [columnConfig, setColumnConfig] =
|
|
useState<FeedSackColumnConfig[]>(DEFAULT_FEED_SACK_COLUMNS);
|
|
const [initialBalance, setInitialBalance] = useState<CycleFeedInitialBalance | null>(null);
|
|
|
|
const feedSaldoEffectiveDate = useMemo(() => {
|
|
const cappedEffectiveDate = new Date(effectiveDate);
|
|
cappedEffectiveDate.setHours(0, 0, 0, 0);
|
|
|
|
if (!cycleData.startDate) {
|
|
return cappedEffectiveDate;
|
|
}
|
|
|
|
const cycleEndDate = cycleData.endDate
|
|
? new Date(cycleData.endDate)
|
|
: (() => {
|
|
const derivedEndDate = new Date(cycleData.startDate);
|
|
derivedEndDate.setDate(
|
|
derivedEndDate.getDate() + Math.max(0, (cycleData.totalDays || 1) - 1)
|
|
);
|
|
return derivedEndDate;
|
|
})();
|
|
|
|
cycleEndDate.setHours(0, 0, 0, 0);
|
|
|
|
return cappedEffectiveDate <= cycleEndDate ? cappedEffectiveDate : cycleEndDate;
|
|
}, [cycleData.endDate, cycleData.startDate, cycleData.totalDays, effectiveDate]);
|
|
|
|
const displayedKpis = useMemo(() => {
|
|
const karungKpi = kpis.karungDituang;
|
|
const reportedDay = Math.max(0, Math.round(kpis.umur?.value || 0));
|
|
|
|
if (!cycleData.startDate || !selectedKandangName || reportedDay <= 0) {
|
|
return kpis;
|
|
}
|
|
|
|
const usageCameraNames = getUsageCameraNamesForKandang(
|
|
columnConfig,
|
|
selectedKandangId,
|
|
selectedKandangName
|
|
);
|
|
|
|
if (usageCameraNames.length === 0) {
|
|
return kpis;
|
|
}
|
|
|
|
const cycleStart = new Date(cycleData.startDate);
|
|
cycleStart.setHours(0, 0, 0, 0);
|
|
|
|
const getLocalDateString = (d: Date) => {
|
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
};
|
|
|
|
const dailyTotals = new Map<string, number>();
|
|
for (const entry of feedSackHistory) {
|
|
if (!usageCameraNames.includes(entry.cameraName)) continue;
|
|
|
|
const entryDate = new Date(entry.timestamp);
|
|
entryDate.setHours(0, 0, 0, 0);
|
|
if (entryDate < cycleStart) continue;
|
|
|
|
const dateKey = getLocalDateString(entryDate);
|
|
if (!dateKey) continue;
|
|
dailyTotals.set(dateKey, (dailyTotals.get(dateKey) || 0) + (entry.count || 0));
|
|
}
|
|
|
|
const history: number[] = [];
|
|
for (let dayIndex = 0; dayIndex <= reportedDay; dayIndex += 1) {
|
|
const date = new Date(cycleStart);
|
|
date.setDate(date.getDate() + dayIndex);
|
|
const dateKey = getLocalDateString(date);
|
|
if (!dateKey) {
|
|
history.push(0);
|
|
continue;
|
|
}
|
|
history.push(dailyTotals.get(dateKey) || 0);
|
|
}
|
|
|
|
const targetDate = new Date(cycleStart);
|
|
targetDate.setDate(targetDate.getDate() + reportedDay);
|
|
const targetDateKey = getLocalDateString(targetDate);
|
|
const totalKarung = dailyTotals.get(targetDateKey) || 0;
|
|
|
|
return {
|
|
...kpis,
|
|
karungDituang: {
|
|
title: karungKpi?.title || 'Karung Dituang',
|
|
value: totalKarung,
|
|
unit: 'karung',
|
|
icon: karungKpi?.icon || '🛢️',
|
|
trend: history,
|
|
status: 'ok' as const,
|
|
targetView: 'feedSackCounting' as const,
|
|
},
|
|
};
|
|
}, [
|
|
cycleData.startDate,
|
|
columnConfig,
|
|
feedSackHistory,
|
|
kpis,
|
|
selectedKandangId,
|
|
selectedKandangName,
|
|
]);
|
|
|
|
// Load column config from database on mount
|
|
useEffect(() => {
|
|
apiClient
|
|
.getFeedSackColumnConfig()
|
|
.then((config: FeedSackColumnConfig[]) => {
|
|
if (config && config.length > 0) {
|
|
const normalizedConfig: FeedSackColumnConfig[] = normalizeFeedSackColumnConfigs(config);
|
|
setColumnConfig(normalizedConfig);
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
console.error('Failed to load column config:', error);
|
|
});
|
|
}, []);
|
|
|
|
// Load initial balance for active cycle
|
|
useEffect(() => {
|
|
const fetchInitialBalance = async () => {
|
|
try {
|
|
const balance = await apiClient.getCycleFeedInitialBalance(cycleData.id);
|
|
setInitialBalance(balance);
|
|
} catch (error) {
|
|
console.error('Failed to fetch initial balance:', error);
|
|
setInitialBalance(null);
|
|
}
|
|
};
|
|
|
|
if (cycleData.id) {
|
|
fetchInitialBalance();
|
|
}
|
|
}, [cycleData.id]);
|
|
|
|
useLayoutEffect(() => {
|
|
const duration = performance.now() - mountStart.current;
|
|
performanceService.recordComponentTiming('Dashboard', duration);
|
|
}, []);
|
|
|
|
return (
|
|
<main className="px-4 sm:px-6 lg:px-8 pb-8 space-y-8">
|
|
{/* KPI Cards */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6 gap-6">
|
|
{/* FIX: Explicitly type 'kpi' as Kpi to resolve type errors. */}
|
|
{Object.entries(displayedKpis).map(([key, kpi]: [string, Kpi]) => {
|
|
// Show loading state for weight-related KPIs (use keys for type safety)
|
|
const isWeightRelated = ['weight', 'fcr', 'eef'].includes(key);
|
|
return (
|
|
<KpiCard
|
|
key={kpi.title}
|
|
{...kpi}
|
|
onClick={kpi.targetView ? () => setView(kpi.targetView!) : undefined}
|
|
isLoading={isWeightRelated && isRefreshingWeightKpi}
|
|
effectiveDate={effectiveDate}
|
|
showDateDisclaimer={isShowingPreviousDayData}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* AI Insight Card */}
|
|
<AiInsightCard kandangId={selectedKandangId ? selectedKandangId.toString() : null} />
|
|
|
|
{/* Karung Dituang Hari Ini Card */}
|
|
<KarungDituangTodayCard
|
|
karungKpi={displayedKpis.karungDituang}
|
|
effectiveDate={effectiveDate}
|
|
/>
|
|
|
|
{/* Saldo Karung Pakan Card */}
|
|
<FeedSaldoCard
|
|
iotData={feedSackHistory}
|
|
columnConfig={columnConfig}
|
|
startDate={new Date(cycleData.startDate)}
|
|
saldoAwal={initialBalance?.saldoAwal || 0}
|
|
effectiveDate={feedSaldoEffectiveDate}
|
|
/>
|
|
|
|
{/* Chicken Counting Dashboard */}
|
|
<ChickenCountingDashboard
|
|
selectedKandangId={selectedKandangId}
|
|
onKandangChange={onKandangChange}
|
|
kandangs={kandangs}
|
|
/>
|
|
|
|
{/* Daily Weight Gain Chart */}
|
|
<DailyWeightGainChart data={dailyWeightGainData} startDate={cycleData.startDate} />
|
|
</main>
|
|
);
|
|
};
|
|
|
|
export default Dashboard;
|