Files
dashboard-cpsp/components/Dashboard.tsx
T

332 lines
13 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
import type {
AIInsight,
ChickenCounting,
ChickenWeight,
Karung,
KPI,
LatestAvg,
} from '../types/api.ts';
import type { View } from '../types/common.ts';
import { useFarm } from '../context/FarmContext.tsx';
import KpiCard, { type KpiCardModel } from './KpiCard.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
import { formatNumber, formatDateRange } from '../utils/format.ts';
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx';
import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx';
import type { DashboardReportType } from '../types/dashboardReport.ts';
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 } = useFarm();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [kpis, setKpis] = useState<KPI[]>([]);
const [countings, setCountings] = useState<ChickenCounting[]>([]);
const [weights, setWeights] = useState<ChickenWeight[]>([]);
const [karung, setKarung] = useState<Karung | null>(null);
const [latestAvg, setLatestAvg] = useState<LatestAvg | null>(null);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [reportOpen, setReportOpen] = useState(false);
const [reportType, setReportType] = useState<DashboardReportType>('daily');
useEffect(() => {
if (!selectedCycle) {
setKpis([]);
setCountings([]);
setWeights([]);
setKarung(null);
setLatestAvg(null);
setInsights([]);
return;
}
const cycleId = selectedCycle.id;
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const [kpiRows, countingRows, weightRows, insightRows] = await Promise.all([
api.kpis.list({ cycle_id: cycleId }),
api.countings.list({ cycle_id: cycleId }),
api.weights.list({ cycle_id: cycleId }),
api.insights.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;
}
let avg: LatestAvg | null = null;
try {
avg = await api.weights.latestAvg({ cycle_id: cycleId });
} catch (err) {
if (!(err instanceof ApiError && err.status === 404)) throw err;
}
if (cancelled) return;
setKpis(kpiRows);
setCountings(countingRows);
setWeights(weightRows);
setKarung(latestKarung);
setLatestAvg(avg);
setInsights(insightRows);
} catch (err) {
if (!cancelled) setError(errorMessage(err));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedCycle]);
const cards: KpiCardModel[] = useMemo(() => {
const latest = kpis[0];
const fcrTrend = chronological(kpis).map((row) => row.fcr);
const eefTrend = chronological(kpis).map((row) => row.eef);
const popTrend = chronological(countings).map((row) => row.total_count);
const weightTrend = chronological(weights).map((row) => row.average_weight);
const age = latest?.age ?? selectedCycle?.current_day ?? null;
return [
{
key: 'umur',
title: 'Umur',
value: age,
unit: 'hari',
icon: '📅',
status: age == null ? 'na' : 'ok',
},
{
key: 'populasi',
title: 'Populasi',
value: latest?.chicken_life ?? countings[0]?.total_count ?? null,
unit: 'ekor',
icon: '🐔',
trend: popTrend,
status: 'ok',
targetView: 'dataManagement',
},
{
key: 'karung',
title: 'Karung Dituang',
value: karung?.out_today ?? karung?.feed_use_today ?? null,
unit: 'karung',
icon: '🛢️',
status: karung ? 'ok' : 'na',
targetView: 'feedSackCounting',
},
{
key: 'weight',
title: 'Berat AVG',
value: latestAvg?.average_weight ?? weights[0]?.average_weight ?? null,
unit: 'gram',
icon: '⚖️',
trend: weightTrend,
status: latestAvg || weights[0] ? 'ok' : 'na',
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, countings, weights, karung, latestAvg, selectedCycle]);
if (!selectedCycle) return <EmptyFarm onSettings={() => setView('kandangSettings')} />;
const feedTotal = kpis[0]?.feed_total ?? 0;
const feedWeightKg = feedTotal * 50;
const feedInitialBalance = selectedCycle?.feed_initial_balance ?? 0;
const feedInTotal = karung?.in_total ?? 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';
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)} (${selectedCycle.status === 'active' ? 'aktif' : 'selesai'})`}
/>
<DashboardReportCtas
cycleClosed={selectedCycle.status === 'closed'}
hasDailySnapshot={false}
hasEndCycleSnapshot={false}
onOpenDaily={() => {
setReportType('daily');
setReportOpen(true);
}}
onOpenEndCycle={() => {
setReportType('end_cycle');
setReportOpen(true);
}}
/>
</div>
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
</div>
)}
{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 Pakan</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 Feed In</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 Feed Out</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">
<h2 className="text-lg font-bold text-gray-800 mb-3">AI Insight</h2>
{insights.length === 0 ? (
<Banner>Belum ada insight untuk siklus ini.</Banner>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{insights.slice(0, 4).map((item) => (
<article
key={item.id}
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">
{item.section}
</p>
<h3 className="font-semibold text-gray-800 mt-1">
{item.alert || 'Insight'}
</h3>
</div>
<span className="text-xs text-gray-400">{item.session}</span>
</div>
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
{item.insight_text}
</p>
</article>
))}
</div>
)}
</section>
<DashboardManualInputForm
cycleId={selectedCycle.id}
currentDay={selectedCycle.current_day}
readOnly={selectedCycle.status !== 'active'}
/>
</>
)}
<ReportPreviewOverlay
open={reportOpen}
reportType={reportType}
onClose={() => setReportOpen(false)}
/>
</div>
);
};
export default Dashboard;