350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
|
|
import type { ChickenWeight, LatestAvg } from '../types/api.ts';
|
|
import { useFarm } from '../context/FarmContext.tsx';
|
|
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
|
import {
|
|
formatDateId,
|
|
formatDateRange,
|
|
formatNumber,
|
|
resolveCycleStatus,
|
|
} from '../utils/format.ts';
|
|
import { formatWeight } from '../utils/weight.ts';
|
|
import { buildWeightInsightContext } from '../utils/insightContextBuilders.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';
|
|
import { PageAiInsight } from './shared/PageAiInsight.tsx';
|
|
|
|
const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
|
const { selectedCycle, selectedSite, selectedKandang } = useFarm();
|
|
const [rows, setRows] = useState<ChickenWeight[]>([]);
|
|
const [latest, setLatest] = useState<LatestAvg | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [syncing, setSyncing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [notice, setNotice] = useState<string | null>(null);
|
|
const [selectedDate, setSelectedDate] = useState('');
|
|
|
|
const isClosedCycle = !selectedCycle || resolveCycleStatus(selectedCycle) !== 'active';
|
|
|
|
const reload = async (cycleId: number) => {
|
|
setError(null);
|
|
try {
|
|
const list = await api.weights.list({ cycle_id: cycleId });
|
|
setRows(list);
|
|
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);
|
|
setSelectedDate('');
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
(async () => {
|
|
setLoading(true);
|
|
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 handleSync = async () => {
|
|
if (!selectedCycle || isClosedCycle) return;
|
|
setSyncing(true);
|
|
setError(null);
|
|
setNotice(null);
|
|
try {
|
|
await api.weights.sync({ cycle_id: selectedCycle.id });
|
|
setNotice('Sinkron berat edge berhasil.');
|
|
await reload(selectedCycle.id);
|
|
} catch (err) {
|
|
setError(errorMessage(err));
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
};
|
|
|
|
const docWeight = selectedCycle?.chick_in_weight ?? 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 ?? '') : '';
|
|
|
|
// 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]);
|
|
|
|
const insightContext = useMemo(() => {
|
|
if (!selectedKandang) return null;
|
|
return buildWeightInsightContext({
|
|
kandangId: selectedKandang.id,
|
|
kandangName: selectedKandang.kandang_name,
|
|
cycleId: selectedCycle?.id,
|
|
totalDays: selectedCycle?.total_days ?? null,
|
|
docWeight: docWeight,
|
|
weightRows: chartRows,
|
|
averageWeight: kpiLatest?.average_weight ?? null,
|
|
uniformity: kpiLatest?.uniformity ?? null,
|
|
adg: kpiLatest?.average_daily_gain ?? null,
|
|
});
|
|
}, [selectedKandang, selectedCycle, docWeight, chartRows, kpiLatest]);
|
|
|
|
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
|
|
|
return (
|
|
<main className="pb-8">
|
|
<PageHeader
|
|
title="Dashboard Berat Ayam"
|
|
icon={<i className="fa-solid fa-chart-line" />}
|
|
subtitle={`Analisis pertumbuhan bobot ayam secara real-time · ${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
|
|
actions={
|
|
<button
|
|
type="button"
|
|
onClick={handleSync}
|
|
disabled={syncing || isClosedCycle}
|
|
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-60 hover:bg-red-700 transition-colors"
|
|
>
|
|
{syncing ? (
|
|
<>
|
|
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
|
Sync...
|
|
</>
|
|
) : (
|
|
<>
|
|
<i className="fa-solid fa-arrows-rotate mr-2"></i>
|
|
Sync
|
|
</>
|
|
)}
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
{error && (
|
|
<div className="mb-4">
|
|
<Banner kind="error">{error}</Banner>
|
|
</div>
|
|
)}
|
|
{notice && (
|
|
<div className="mb-4">
|
|
<Banner kind="success">{notice}</Banner>
|
|
</div>
|
|
)}
|
|
|
|
{loading && rows.length === 0 ? (
|
|
<Spinner />
|
|
) : (
|
|
<div className="space-y-8">
|
|
{selectedKandang && insightContext ? (
|
|
<PageAiInsight
|
|
topic="berat_ayam"
|
|
cacheKey={`wm-k${selectedKandang.id}-c${selectedCycle.id}-day${selectedCycle.current_day}-rows${chartRows.length}`}
|
|
contextData={insightContext}
|
|
chartSpec={{
|
|
type: 'trend',
|
|
historyKey: 'tren_harian',
|
|
title: 'Bobot aktual vs target',
|
|
unit: 'gram',
|
|
gap: { actualField: 'bobot_gram', targetField: 'target_gram' },
|
|
series: [
|
|
{ field: 'bobot_gram', label: 'Bobot aktual', color: '#7c3aed' },
|
|
{ field: 'target_gram', label: 'Target', color: '#9ca3af', dashed: true },
|
|
],
|
|
}}
|
|
scalarScope={{
|
|
historyKey: 'tren_harian',
|
|
derive: {
|
|
// Match Weight page StatCards: latest valid value, never a window mean.
|
|
bobotTerakhir_gram: {
|
|
field: 'bobot_gram',
|
|
agg: 'lastValid',
|
|
replaces: 'averageWeight',
|
|
},
|
|
// Target weight exists from day 0; 0 means not set yet
|
|
targetBobot_gram: {
|
|
field: 'target_gram',
|
|
agg: 'last',
|
|
replaces: 'targetWeight',
|
|
emptyAs: 0,
|
|
},
|
|
// ADG: 0 is valid (no gain yet), use 'last' with emptyAs: 0
|
|
pertambahanHarian_gramPerHari: {
|
|
field: 'adg_gram_per_hari',
|
|
agg: 'last',
|
|
replaces: 'adg',
|
|
emptyAs: 0,
|
|
},
|
|
},
|
|
drop: ['averageWeight', 'targetWeight', 'adg'],
|
|
}}
|
|
/>
|
|
) : (
|
|
<section>
|
|
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
|
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
|
AI Insight Berat Ayam
|
|
</h2>
|
|
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
|
|
</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} docWeight={docWeight} />
|
|
</div>
|
|
<div className="lg:col-span-2">
|
|
<DailyWeightProgressionChart rows={chartRows} docWeight={docWeight} />
|
|
</div>
|
|
</div>
|
|
<DailyWeightGainChart rows={chartRows} docWeight={docWeight} />
|
|
</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} docWeight={docWeight} />
|
|
|
|
{/* 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 "Sync" untuk menarik prediksi berat dari edge vision</li>
|
|
<li>• Hari 0 memakai berat DOC dan uniformity 0% (bukan data edge)</li>
|
|
<li>• Target berat menggunakan standar performa berdasarkan DOC</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
);
|
|
};
|
|
|
|
export default WeightMonitoringPage;
|