first commit
This commit is contained in:
commit
367533c02b
171 files changed
+101175
No files matched your search
@@ -0,0 +1,313 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
|
||||
import type { AIInsight, ChickenWeight, LatestAvg } from '../types/api.ts';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import { Banner, EmptyFarm, Spinner } from './ui/Feedback.tsx';
|
||||
import { formatDateId, formatNumber } from '../utils/format.ts';
|
||||
import { formatWeight } from '../utils/weight.ts';
|
||||
import WeightStats from './weight/WeightStats.tsx';
|
||||
import DailyWeightProgressionChart from './weight/DailyWeightProgressionChart.tsx';
|
||||
import DailyWeightGainChart from './weight/DailyWeightGainChart.tsx';
|
||||
import DailyDetailPanel from './weight/DailyDetailPanel.tsx';
|
||||
|
||||
const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
const { selectedCycle } = useFarm();
|
||||
const [rows, setRows] = useState<ChickenWeight[]>([]);
|
||||
const [latest, setLatest] = useState<LatestAvg | null>(null);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedDate, setSelectedDate] = useState('');
|
||||
|
||||
const reload = async (cycleId: number) => {
|
||||
setError(null);
|
||||
try {
|
||||
const [list, insightRows] = await Promise.all([
|
||||
api.weights.list({ cycle_id: cycleId }),
|
||||
api.insights.list({ cycle_id: cycleId }),
|
||||
]);
|
||||
setRows(list);
|
||||
setInsights(insightRows);
|
||||
try {
|
||||
setLatest(await api.weights.latestAvg({ cycle_id: cycleId }));
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) setLatest(null);
|
||||
else throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCycle) {
|
||||
setRows([]);
|
||||
setLatest(null);
|
||||
setInsights([]);
|
||||
setSelectedDate('');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setRefreshing(false);
|
||||
try {
|
||||
await reload(selectedCycle.id);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedCycle]);
|
||||
|
||||
// Days without a weighing are stored as 0 g. Charts skip those days so the
|
||||
// line does not drop to zero at the end of a cycle, mirroring the old
|
||||
// dashboard which only plotted days with actual timbangan data.
|
||||
const chartRows = useMemo(() => rows.filter((row) => row.average_weight > 0), [rows]);
|
||||
|
||||
// Default the date picker to the newest row with a real weighing once data
|
||||
// loads, so the detail panel opens with meaningful values.
|
||||
useEffect(() => {
|
||||
if (!selectedDate) {
|
||||
const newestMeaningful = chartRows[0] ?? rows[0];
|
||||
if (newestMeaningful) setSelectedDate(newestMeaningful.date);
|
||||
}
|
||||
}, [rows, chartRows, selectedDate]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!selectedCycle) return;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await reload(selectedCycle.id);
|
||||
} catch {
|
||||
// error state is set inside reload
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalDays = selectedCycle?.total_days ?? 35;
|
||||
|
||||
const selectedRow = useMemo(
|
||||
() => rows.find((row) => row.date === selectedDate) ?? null,
|
||||
[rows, selectedDate]
|
||||
);
|
||||
|
||||
const minDate = rows.length > 0 ? (rows[rows.length - 1]?.date ?? '') : '';
|
||||
const maxDate = rows.length > 0 ? (rows[0]?.date ?? '') : '';
|
||||
|
||||
const weightInsights = useMemo(() => {
|
||||
const matched = insights.filter((item) => {
|
||||
const section = `${item.section ?? ''} ${item.alert ?? ''}`.toLowerCase();
|
||||
return section.includes('berat') || section.includes('weight') || section.includes('timbang');
|
||||
});
|
||||
return (matched.length > 0 ? matched : insights).slice(0, 4);
|
||||
}, [insights]);
|
||||
|
||||
// Prefer the latest non-zero row for the KPI card so a closed cycle with
|
||||
// trailing empty days does not read as "0 g".
|
||||
const kpiLatest = useMemo<LatestAvg | null>(() => {
|
||||
if (!latest) return null;
|
||||
if (latest.average_weight > 0) return latest;
|
||||
const fallback = rows.find((row) => row.average_weight > 0);
|
||||
if (!fallback) return null;
|
||||
return {
|
||||
average_weight: fallback.average_weight,
|
||||
date: fallback.date,
|
||||
age: fallback.age,
|
||||
uniformity: fallback.uniformity,
|
||||
average_daily_gain: fallback.average_daily_gain,
|
||||
cycle: fallback.cycle,
|
||||
kandang: fallback.kandang,
|
||||
};
|
||||
}, [latest, rows]);
|
||||
|
||||
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
||||
|
||||
return (
|
||||
<main className="pb-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6">
|
||||
<div className="flex items-center">
|
||||
<div className="bg-red-600 text-white p-3 rounded-lg mr-4">
|
||||
<i className="fa-solid fa-chart-line text-2xl sm:text-3xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl lg:text-3xl font-bold text-gray-800">Dashboard Berat Ayam</h1>
|
||||
<p className="mt-1 text-gray-600">
|
||||
Analisis pertumbuhan dan kesejahteraan bobot ayam secara real-time.
|
||||
</p>
|
||||
{!loading && latest?.date && (
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
<i className="fa-solid fa-clock mr-1"></i>
|
||||
Terakhir diperbarui: {formatDateId(latest.date)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || loading}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
title="Refresh data manual"
|
||||
>
|
||||
<i className={`fa-solid fa-refresh ${refreshing || loading ? 'fa-spin' : ''}`}></i>
|
||||
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<Banner kind="error">{error}</Banner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && rows.length === 0 ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* AI Insight */}
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">AI Insight</h2>
|
||||
{weightInsights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{weightInsights.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>
|
||||
|
||||
{/* TOP SECTION: DATA SELURUH SIKLUS */}
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-1">
|
||||
<WeightStats latest={kpiLatest} totalDays={totalDays} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<DailyWeightProgressionChart rows={chartRows} totalDays={totalDays} />
|
||||
</div>
|
||||
</div>
|
||||
<DailyWeightGainChart rows={chartRows} totalDays={totalDays} />
|
||||
</div>
|
||||
|
||||
{/* ===== SEPARATOR: DATA PER HARI ===== */}
|
||||
<div className="border-t-4 border-gray-300 pt-8 mt-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800">Data Per Hari</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Pilih tanggal untuk melihat detail data harian
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 bg-white px-4 py-2 rounded-lg border border-gray-300 shadow-sm">
|
||||
<i className="fa-solid fa-calendar text-red-600"></i>
|
||||
<div>
|
||||
<label htmlFor="daily-date-picker" className="text-xs text-gray-500 block mb-1">
|
||||
Pilih Tanggal:
|
||||
</label>
|
||||
<input
|
||||
id="daily-date-picker"
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
min={minDate || undefined}
|
||||
max={maxDate || undefined}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
className="px-2 py-1 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DailyDetailPanel row={selectedRow} totalDays={totalDays} />
|
||||
|
||||
{/* Riwayat lengkap */}
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-3">Riwayat Berat Harian</h3>
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
{['Tanggal', 'Umur', 'DOC', 'AVG', 'Uniformity', 'ADG'].map((h) => (
|
||||
<th key={h} className="px-4 py-3 text-left font-semibold">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-6 text-center text-gray-400">
|
||||
Belum ada data berat untuk siklus ini.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<tr key={row.id} className="border-t">
|
||||
<td className="px-4 py-2">{formatDateId(row.date)}</td>
|
||||
<td className="px-4 py-2">{row.age}</td>
|
||||
<td className="px-4 py-2">
|
||||
{formatWeight(row.doc_weight, { decimals: 0 })}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-semibold">
|
||||
{formatWeight(row.average_weight, { stripTrailingZeros: true })}
|
||||
</td>
|
||||
<td className="px-4 py-2">{formatNumber(row.uniformity, 2, 2, true)}%</td>
|
||||
<td className="px-4 py-2">
|
||||
{formatNumber(row.average_daily_gain, 2, 2, true)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<i className="fa-solid fa-info-circle text-blue-600 text-xl mr-3 mt-1"></i>
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-900 mb-2">Informasi Pembaruan Data</h3>
|
||||
<ul className="text-sm text-blue-800 space-y-1">
|
||||
<li>• Klik tombol "Refresh" untuk memperbarui data secara manual</li>
|
||||
<li>• Grafik menampilkan data dari awal siklus hingga hari ini</li>
|
||||
<li>• Target berat menggunakan standar performa Ross 308</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeightMonitoringPage;
|
||||
Reference in new issue
Block a user