491 lines
20 KiB
TypeScript
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;
|