Files
dashboard-cpsp/components/Dashboard.tsx
T

491 lines
20 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
import type {
ChickenCounting,
ChickenWeight,
Karung,
KPI,
ManualInput,
PanelIoT,
} from '../types/api.ts';
import type { View } from '../types/common.ts';
import { useFarm } from '../context/FarmContext.tsx';
import KpiCard, { type KpiCardModel, type KpiTrendPoint } from './KpiCard.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
import {
cycleDayForDate,
formatDateId,
formatDateRange,
formatNumber,
resolveCycleStatus,
} from '../utils/format.ts';
import { throughVisibleDate } from '../utils/visibility.ts';
import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts';
import { getDailyMortalityStatus } from '../utils/mortality.ts';
import {
buildDashboardInsightContext,
buildDashboardInsightMetrics,
buildIotDayAggregates,
type EndCycleIotDayAggregate,
} from '../utils/insightContextBuilders.ts';
import { getTempStandardByDay } from '../utils/cp707EnvStandards.ts';
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
import DashboardCloseCycleControls from './dashboard/DashboardCloseCycleControls.tsx';
import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx';
import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx';
import CountingAccuracyWarningBanner from './counting/CountingAccuracyWarningBanner.tsx';
import type { DashboardReportType } from '../types/dashboardReport.ts';
import { AiInsightCard } from './AiInsightCard.tsx';
type Props = { setView: (view: View) => void };
function chronological<T>(rows: T[]): T[] {
return [...rows].reverse();
}
const Dashboard: React.FC<Props> = ({ setView }) => {
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [kpis, setKpis] = useState<KPI[]>([]);
const [countings, setCountings] = useState<ChickenCounting[]>([]);
const [manualInputs, setManualInputs] = useState<ManualInput[]>([]);
const [weights, setWeights] = useState<ChickenWeight[]>([]);
const [karung, setKarung] = useState<Karung | null>(null);
const [iotDayAggregates, setIotDayAggregates] = useState<EndCycleIotDayAggregate[]>([]);
const [reportOpen, setReportOpen] = useState(false);
const [reportType, setReportType] = useState<DashboardReportType>('daily');
useEffect(() => {
if (!selectedCycle) {
setKpis([]);
setCountings([]);
setManualInputs([]);
setWeights([]);
setKarung(null);
setIotDayAggregates([]);
return;
}
const cycleId = selectedCycle.id;
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const [kpiRows, countingRows, manualRows, weightRows] = await Promise.all([
api.kpis.list({ cycle_id: cycleId }),
api.countings.list({ cycle_id: cycleId }),
api.manualInputs.list({ cycle_id: cycleId }),
api.weights.list({ cycle_id: cycleId }),
]);
let latestKarung: Karung | null = null;
try {
latestKarung = await api.karungs.latest({ cycle_id: cycleId });
} catch (err) {
if (!(err instanceof ApiError && err.status === 404)) throw err;
}
if (cancelled) return;
setKpis(kpiRows);
setCountings(countingRows);
setManualInputs(manualRows);
setWeights(weightRows);
setKarung(latestKarung);
// Best-effort IoT aggregates for end-cycle root cause (non-blocking on failure).
try {
const flockId =
kpiRows.find((row) => row.flock_id != null)?.flock_id ??
(selectedKandang
? (await api.flocks.list({ kandang_id: selectedKandang.id }))[0]?.id
: null);
if (flockId == null) {
setIotDayAggregates([]);
} else {
let iotRows: PanelIoT[] = [];
try {
iotRows = await api.panelIot.list({ cycle_id: cycleId, flock_id: flockId });
} catch {
// Some deployments require a date — fall back to sampling recent dates.
const dates = await api.panelIot.dates({ cycle_id: cycleId, flock_id: flockId });
const sample = [...dates].sort().slice(-14);
const batches = await Promise.all(
sample.map((date) =>
api.panelIot.list({ cycle_id: cycleId, flock_id: flockId, date }).catch(() => [])
)
);
iotRows = batches.flat();
}
if (!cancelled) {
setIotDayAggregates(
buildIotDayAggregates({
rows: iotRows,
cycleStartDate: selectedCycle.start_date,
getTempTarget: getTempStandardByDay,
})
);
}
}
} catch {
if (!cancelled) setIotDayAggregates([]);
}
} catch (err) {
if (!cancelled) setError(errorMessage(err));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedCycle, selectedKandang]);
const cards: KpiCardModel[] = useMemo(() => {
// All cards share the latest complete KPI day (not calendar current_day / live IoT).
const visibleThrough = selectedCycle?.visible_through_date;
const visibleKpis = throughVisibleDate(kpis, visibleThrough);
const latest = visibleKpis[0] ?? null;
const anchorDate = latest?.date ?? null;
const kpisThroughAnchor = anchorDate ? visibleKpis.filter((row) => row.date <= anchorDate) : [];
const weightsThroughAnchor = anchorDate
? throughVisibleDate(weights, visibleThrough).filter((row) => row.date <= anchorDate)
: [];
const weightOnAnchor = anchorDate
? (weightsThroughAnchor.find((row) => row.date === anchorDate) ?? weightsThroughAnchor[0])
: null;
const startDate = selectedCycle?.start_date;
const trendLabel = (isoDate: string): string => {
const day = startDate ? cycleDayForDate(startDate, isoDate) : 0;
return `Hari ${day} · ${formatDateId(isoDate)}`;
};
const fcrTrend: KpiTrendPoint[] = chronological(kpisThroughAnchor).map((row) => ({
value: row.fcr,
label: trendLabel(row.date),
}));
const eefTrend: KpiTrendPoint[] = chronological(kpisThroughAnchor).map((row) => ({
value: row.eef,
label: trendLabel(row.date),
}));
const mortalityTrend: KpiTrendPoint[] = chronological(kpisThroughAnchor).map((row) => ({
value: row.mortality,
label: trendLabel(row.date),
}));
const weightTrend: KpiTrendPoint[] = chronological(weightsThroughAnchor).map((row) => ({
value: row.average_weight,
label: trendLabel(row.date),
}));
const age = latest?.age ?? null;
const mortalityToday = latest?.mortality ?? null;
const docInCount = selectedCycle?.doc_in_count ?? null;
const mortalityStatus = getDailyMortalityStatus(mortalityToday, docInCount);
const feedToday = latest != null ? latest.feed : null;
const weightToday =
latest && latest.iot_weight > 0
? latest.iot_weight
: (weightOnAnchor?.average_weight ?? null);
return [
{
key: 'umur',
title: 'Umur',
value: age,
unit: 'hari',
icon: '📅',
status: age == null ? 'na' : 'ok',
},
{
key: 'mortality',
title: 'Mortalitas',
value: mortalityToday,
unit: 'ekor',
icon: '📉',
trend: mortalityTrend,
status: mortalityStatus,
targetView: 'dataManagement',
},
{
key: 'karung',
title: 'Karung Dituang',
value: feedToday,
unit: 'karung',
icon: '🛢️',
status: feedToday == null ? 'na' : 'ok',
targetView: 'feedSackCounting',
},
{
key: 'weight',
title: 'Berat AVG',
value: weightToday,
unit: 'gram',
icon: '⚖️',
trend: weightTrend,
status: weightToday == null ? 'na' : 'ok',
targetView: 'weightMonitoring',
},
{
key: 'fcr',
title: 'FCR',
value: latest?.fcr ?? null,
unit: '',
icon: '📈',
trend: fcrTrend,
status: latest ? 'ok' : 'na',
targetView: 'fcrDashboard',
},
{
key: 'eef',
title: 'EEF',
value: latest?.eef ?? null,
unit: '',
icon: '🎯',
trend: eefTrend,
status: latest ? 'ok' : 'na',
targetView: 'eefDashboard',
},
];
}, [kpis, weights, selectedCycle]);
const countingAccuracyWarning = useMemo(() => {
if (!selectedCycle) return null;
return getCountingAccuracyWarning({
cycle: selectedCycle,
countings,
kpis,
manualInputs,
});
}, [selectedCycle, countings, kpis, manualInputs]);
const insightContext = useMemo(() => {
if (selectedKandangId == null) return null;
const visibleThrough = selectedCycle?.visible_through_date;
const visibleKpis = throughVisibleDate(kpis, visibleThrough);
const latest = visibleKpis[0] ?? null;
const anchorDate = latest?.date ?? null;
const weightsThroughAnchor = anchorDate
? throughVisibleDate(weights, visibleThrough).filter((row) => row.date <= anchorDate)
: [];
const weightOnAnchor = anchorDate
? (weightsThroughAnchor.find((row) => row.date === anchorDate) ?? weightsThroughAnchor[0])
: null;
const bobotAvg =
latest && latest.iot_weight > 0
? latest.iot_weight
: (weightOnAnchor?.average_weight ?? null);
return buildDashboardInsightContext({
kandangId: selectedKandangId,
kandangName: selectedKandang?.kandang_name,
cycleId: selectedCycle?.id,
// Prefer latest KPI age — cycle.current_day can be ahead of available data.
hari_ke: latest?.age ?? selectedCycle?.current_day ?? null,
totalDays: selectedCycle?.total_days ?? null,
fcr_terakhir: latest?.fcr ?? null,
eef_terakhir: latest?.eef ?? null,
bobot_avg_gram: bobotAvg,
mortalitas_hari_ini_ekor: latest?.mortality ?? null,
mortalitas_kumulatif_persen:
latest?.chicken_life_percentage == null
? null
: Math.round((100 - latest.chicken_life_percentage) * 100) / 100,
karung_dituang_hari_ini:
latest != null ? latest.feed : (karung?.feed_use_today ?? karung?.out_today ?? null),
pakan_kumulatif_karung: karung?.feed_use_total ?? latest?.feed_total ?? null,
ayam_hidup_ekor:
latest != null ? Math.max(0, latest.chicken_life - (latest.harvest_total ?? 0)) : null,
persen_hidup: latest?.chicken_life_percentage ?? null,
doc_in_ekor: selectedCycle?.doc_in_count ?? null,
kematian_kumulatif_ekor: latest?.mortality_total ?? null,
panen_kumulatif_ekor: latest?.harvest_total ?? null,
// KPI harvest_weight_total is grams; insight context expects kg.
tonase_panen_kg:
latest?.harvest_weight_total != null ? latest.harvest_weight_total / 1000 : null,
uniformity_persen: weightOnAnchor?.uniformity ?? null,
});
}, [selectedKandangId, selectedKandang, selectedCycle, kpis, weights, karung]);
const insightMetrics = useMemo(() => {
if (selectedKandangId == null) return null;
const visibleThrough = selectedCycle?.visible_through_date;
const visibleKpis = throughVisibleDate(kpis, visibleThrough);
const latest = visibleKpis[0] ?? null;
const anchorDate = latest?.date ?? null;
const weightsThroughAnchor = anchorDate
? throughVisibleDate(weights, visibleThrough).filter((row) => row.date <= anchorDate)
: throughVisibleDate(weights, visibleThrough);
return buildDashboardInsightMetrics({
kpis: visibleKpis,
weights: weightsThroughAnchor,
currentDay: latest?.age ?? selectedCycle?.current_day ?? null,
docInCount: selectedCycle?.doc_in_count ?? null,
pakanKumulatifFallback: karung?.feed_use_total ?? latest?.feed_total ?? null,
});
}, [selectedKandangId, selectedCycle, kpis, weights, karung]);
if (!selectedCycle) return <EmptyFarm onSettings={() => setView('kandangSettings')} />;
const feedTotal = karung?.feed_use_total ?? kpis[0]?.feed_total ?? 0;
const feedWeightKg = feedTotal * 50;
const feedInitialBalance = selectedCycle?.feed_initial_balance ?? 0;
const feedInTotal = (karung?.in_total ?? 0) + feedInitialBalance;
const feedOutTotal = karung?.out_total ?? 0;
const feedSaldo = feedInTotal - feedTotal - feedOutTotal;
const feedSaldoColor =
feedSaldo > 0 ? 'text-green-600' : feedSaldo < 0 ? 'text-red-600' : 'text-gray-600';
const cycleStatus = resolveCycleStatus(selectedCycle);
return (
<div>
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-2">
<PageHeader
title="Dashboard"
subtitle={`${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)} (${cycleStatus === 'active' ? 'aktif' : cycleStatus === 'pending_close' ? 'menunggu GM' : 'selesai'})`}
/>
<div className="flex flex-col items-end gap-3 sm:flex-row sm:items-start">
<DashboardCloseCycleControls />
<DashboardReportCtas
cycleClosed={cycleStatus === 'closed'}
hasDailySnapshot={false}
hasEndCycleSnapshot={false}
onOpenDaily={() => {
setReportType('daily');
setReportOpen(true);
}}
onOpenEndCycle={() => {
setReportType('end_cycle');
setReportOpen(true);
}}
/>
</div>
</div>
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
</div>
)}
{countingAccuracyWarning && (
<CountingAccuracyWarningBanner warning={countingAccuracyWarning} />
)}
{loading ? (
<Spinner />
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6 gap-6">
{cards.map((card) => (
<KpiCard
key={card.key}
title={card.title}
value={card.value}
unit={card.unit}
icon={card.icon}
trend={card.trend}
status={card.status}
onClick={card.targetView ? () => setView(card.targetView as View) : undefined}
/>
))}
</div>
<div className="mt-6 rounded-xl border-2 border-blue-200 bg-gradient-to-br from-blue-50 to-indigo-50 p-6 shadow-sm">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-lg bg-blue-500 p-3 text-white">
<i className="fa-solid fa-boxes-stacked text-2xl" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-800">Total Pakan</h2>
<p className="text-sm text-gray-600">Kumulatif karung pakan sejak awal siklus</p>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-white p-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div className="text-center">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-boxes-stacked text-blue-600" />
<p className="text-sm font-semibold text-gray-700">Total Karung Tuang</p>
</div>
<p className="text-3xl font-bold text-blue-600">{formatNumber(feedTotal)}</p>
<p className="mt-1 text-xs text-gray-400">karung</p>
</div>
<div className="text-center sm:border-l-2 sm:border-blue-200 sm:pl-6">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-arrow-down text-green-600" />
<p className="text-sm font-semibold text-gray-700">Total Karung Masuk</p>
</div>
<p className="text-3xl font-bold text-green-600">{formatNumber(feedInTotal)}</p>
<p className="mt-1 text-xs text-gray-400">karung</p>
</div>
<div className="text-center lg:border-l-2 lg:border-blue-200 lg:pl-6">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-arrow-right text-red-600" />
<p className="text-sm font-semibold text-gray-700">Total Karung Keluar</p>
</div>
<p className="text-3xl font-bold text-red-600">{formatNumber(feedOutTotal)}</p>
<p className="mt-1 text-xs text-gray-400">karung</p>
</div>
<div className="text-center lg:border-l-2 lg:border-blue-200 lg:pl-6">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-box-archive text-blue-600" />
<p className="text-sm font-semibold text-gray-700">Saldo Awal</p>
</div>
<p className="text-3xl font-bold text-blue-600">
{formatNumber(feedInitialBalance)}
</p>
<p className="mt-1 text-xs text-gray-400">karung</p>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 border-t border-blue-200 pt-6 sm:grid-cols-2">
<div className="text-center">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-weight-scale text-blue-600" />
<p className="text-sm font-semibold text-gray-700">Total Berat Pakan</p>
</div>
<p className="text-3xl font-bold text-blue-600">{formatNumber(feedWeightKg)}</p>
<p className="mt-1 text-xs text-gray-400">kg</p>
</div>
<div className="text-center sm:border-l-2 sm:border-blue-200 sm:pl-6">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-scale-balanced text-blue-600" />
<p className="text-sm font-semibold text-gray-700">Saldo Karung Pakan</p>
</div>
<p className={`text-3xl font-bold ${feedSaldoColor}`}>
{formatNumber(feedSaldo)}
</p>
<p className="mt-1 text-xs text-gray-400">karung</p>
</div>
</div>
</div>
</div>
<section className="mt-8">
{selectedKandangId != null ? (
<AiInsightCard
kandangId={selectedKandangId}
context={insightContext ?? undefined}
dashboardMetrics={insightMetrics}
iotDayAggregates={iotDayAggregates}
page="dashboard"
/>
) : (
<div className="rounded-xl border border-amber-100 bg-amber-50 p-4 text-sm text-amber-800">
Pilih satu kandang untuk menghasilkan AI Insight. Generate untuk semua kandang tidak
didukung.
</div>
)}
</section>
<DashboardManualInputForm
cycleId={selectedCycle.id}
currentDay={selectedCycle.current_day}
readOnly={cycleStatus !== 'active'}
/>
</>
)}
<ReportPreviewOverlay
open={reportOpen}
reportType={reportType}
onClose={() => setReportOpen(false)}
/>
</div>
);
};
export default Dashboard;