From 83a41fbbcb8723253286deb916dff1dd28ac0601 Mon Sep 17 00:00:00 2001 From: Alberto-Audrix Date: Wed, 7 Oct 2026 11:17:44 +0700 Subject: [PATCH] update all page to refresh immidiately without clicking sync button or refreshing the whole page --- .gitignore | 3 +- .../services/chicken_counting_edge.py | 10 ++ .../apps/operations/services/karung_web.py | 3 + .../apps/operations/services/kpi_rollups.py | 1 + backend/apps/operations/views.py | 8 ++ components/Dashboard.tsx | 106 ++++++++++++------ components/EefDashboardPage.tsx | 70 ++++++++---- components/FcrDashboardPage.tsx | 76 +++++++++---- components/FeedSackCountingPage.tsx | 71 +++++++++--- components/IotPanelPage.tsx | 97 +++++++++++++++- components/WeightMonitoringPage.tsx | 84 +++++++++----- components/counting/DataManagementPage.tsx | 77 +++++++++---- .../dashboard/DashboardManualInputForm.tsx | 15 ++- .../DashboardManualInputForm.test.tsx | 19 ++++ services/apiClient.ts | 1 + 15 files changed, 490 insertions(+), 151 deletions(-) diff --git a/.gitignore b/.gitignore index 743d22d..a865d6f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ __pycache__ docker-compose.override.yml docs/ .superpowers/ -ai-insight/chroma_db/ \ No newline at end of file +ai-insight/chroma_db/ +backend/logs/rag_trace.jsonl \ No newline at end of file diff --git a/backend/apps/operations/services/chicken_counting_edge.py b/backend/apps/operations/services/chicken_counting_edge.py index 83dbb17..e3e1a02 100644 --- a/backend/apps/operations/services/chicken_counting_edge.py +++ b/backend/apps/operations/services/chicken_counting_edge.py @@ -255,6 +255,11 @@ def sync_chicken_counting_for_cycle( obj.save(update_fields=update_fields) upserted.append(day) + if upserted: + from apps.operations.services.kpi_rollups import refresh_kpi_source_fields + + refresh_kpi_source_fields(cycle) + return {"dates": upserted, "upserted": len(upserted), "skipped": False} @@ -329,4 +334,9 @@ def sync_chicken_weight_for_cycle( upserted.append(day) prev_weight = average_weight + if upserted: + from apps.operations.services.kpi_rollups import refresh_kpi_source_fields + + refresh_kpi_source_fields(cycle) + return {"dates": upserted, "upserted": len(upserted), "skipped": False} diff --git a/backend/apps/operations/services/karung_web.py b/backend/apps/operations/services/karung_web.py index e0ad492..29a4606 100644 --- a/backend/apps/operations/services/karung_web.py +++ b/backend/apps/operations/services/karung_web.py @@ -326,6 +326,9 @@ def sync_karung_for_cycle( feed_sack, summary = request_karung(cycle, section=section) history_dates = backfill_karung_from_combined(cycle) recompute_feed_sack_totals(cycle) + from apps.operations.services.kpi_rollups import refresh_kpi_source_fields + + refresh_kpi_source_fields(cycle) feed_sack.refresh_from_db() summary = { **summary, diff --git a/backend/apps/operations/services/kpi_rollups.py b/backend/apps/operations/services/kpi_rollups.py index 54cd060..bb3682f 100644 --- a/backend/apps/operations/services/kpi_rollups.py +++ b/backend/apps/operations/services/kpi_rollups.py @@ -70,6 +70,7 @@ def refresh_kpi_source_fields(cycle: Cycle, *, through: date | None = None) -> i cc = countings.get(day) cw = weights.get(day) fs = feeds.get(day) + if not all([manual, cc, cw, fs]): # Still accumulate harvest age sum from manual-only days so later # complete days keep a correct running Σ(age×harvest). diff --git a/backend/apps/operations/views.py b/backend/apps/operations/views.py index 885b875..17a8e97 100644 --- a/backend/apps/operations/views.py +++ b/backend/apps/operations/views.py @@ -74,6 +74,9 @@ class ManualInputViewSet(CycleScopedViewSet): queryset = ManualInput.objects.select_related("cycle").all() serializer_class = ManualInputSerializer + def applies_visibility_filter(self) -> bool: + return False + class ChickenCountingViewSet(CycleScopedViewSet): """Chicken counting + mortality IoT — not subject to dashboard publish cutoff.""" @@ -166,9 +169,14 @@ class ChickenWeightViewSet(CycleScopedViewSet): class FeedSacksViewSet(CycleScopedViewSet): + """Feed sacks (karung masuk & karung dituang) — not subject to dashboard publish cutoff.""" + queryset = FeedSacks.objects.select_related("cycle").all() serializer_class = FeedSacksSerializer + def applies_visibility_filter(self) -> bool: + return False + @action(detail=False, methods=["get"], url_path="latest") def latest(self, request): cycle_id = request.query_params.get("cycle_id") diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index 48daef1..f5bf9b2 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { api, ApiError, errorMessage } from '../services/apiClient.ts'; import type { ChickenCounting, @@ -44,7 +44,8 @@ function chronological(rows: T[]): T[] { } const Dashboard: React.FC = ({ setView }) => { - const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm(); + const { selectedCycle, selectedKandang, selectedSite, selectedKandangId, reload, updateCycle } = + useFarm(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [kpis, setKpis] = useState([]); @@ -56,27 +57,29 @@ const Dashboard: React.FC = ({ setView }) => { const [reportOpen, setReportOpen] = useState(false); const [reportType, setReportType] = useState('daily'); - useEffect(() => { - if (!selectedCycle) { - setKpis([]); - setCountings([]); - setManualInputs([]); - setWeights([]); - setKarung(null); - setIotDayAggregates([]); - return; - } - const cycleId = selectedCycle.id; - let cancelled = false; - (async () => { - setLoading(true); + const fetchDashboardData = useCallback( + async (isBackground = false) => { + if (!selectedCycle) { + setKpis([]); + setCountings([]); + setManualInputs([]); + setWeights([]); + setKarung(null); + setIotDayAggregates([]); + return; + } + const cycleId = selectedCycle.id; + if (!isBackground) { + setLoading(true); + } setError(null); try { - const [kpiRows, countingRows, manualRows, weightRows] = await Promise.all([ + const [kpiRows, countingRows, manualRows, weightRows, freshCycle] = 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 }), + api.cycles?.retrieve?.(cycleId)?.catch(() => null) ?? Promise.resolve(null), ]); let latestKarung: Karung | null = null; try { @@ -84,7 +87,9 @@ const Dashboard: React.FC = ({ setView }) => { } catch (err) { if (!(err instanceof ApiError && err.status === 404)) throw err; } - if (cancelled) return; + if (freshCycle) { + updateCycle(cycleId, freshCycle); + } setKpis(kpiRows); setCountings(countingRows); setManualInputs(manualRows); @@ -115,29 +120,61 @@ const Dashboard: React.FC = ({ setView }) => { ); iotRows = batches.flat(); } - if (!cancelled) { - setIotDayAggregates( - buildIotDayAggregates({ - rows: iotRows, - cycleStartDate: selectedCycle.start_date, - getTempTarget: getTempStandardByDay, - }) - ); - } + setIotDayAggregates( + buildIotDayAggregates({ + rows: iotRows, + cycleStartDate: selectedCycle.start_date, + getTempTarget: getTempStandardByDay, + }) + ); } } catch { - if (!cancelled) setIotDayAggregates([]); + setIotDayAggregates([]); } } catch (err) { - if (!cancelled) setError(errorMessage(err)); + if (!isBackground) { + setError(errorMessage(err)); + } } finally { - if (!cancelled) setLoading(false); + if (!isBackground) { + setLoading(false); + } + } + }, + [selectedCycle?.id, selectedCycle?.start_date, selectedKandang, updateCycle] + ); + + useEffect(() => { + // Initial fetch (with full loading indicator on cycle change) + void fetchDashboardData(false); + + // Auto-refresh interval: 1 minute (60,000 ms) + const interval = setInterval(() => { + if (document.visibilityState === 'visible') { + void fetchDashboardData(true); + } + }, 60000); + + const handleFocusOrVisibility = () => { + if (document.visibilityState === 'visible') { + void fetchDashboardData(true); } - })(); - return () => { - cancelled = true; }; - }, [selectedCycle, selectedKandang]); + + document.addEventListener('visibilitychange', handleFocusOrVisibility); + window.addEventListener('focus', handleFocusOrVisibility); + + return () => { + clearInterval(interval); + document.removeEventListener('visibilitychange', handleFocusOrVisibility); + window.removeEventListener('focus', handleFocusOrVisibility); + }; + }, [fetchDashboardData]); + + const handleManualInputSaved = useCallback(async () => { + await fetchDashboardData(false); + void reload(); + }, [fetchDashboardData, reload]); const cards: KpiCardModel[] = useMemo(() => { // All cards share the latest complete KPI day (not calendar current_day / live IoT). @@ -475,6 +512,7 @@ const Dashboard: React.FC = ({ setView }) => { cycleId={selectedCycle.id} currentDay={selectedCycle.current_day} readOnly={cycleStatus !== 'active'} + onSaved={handleManualInputSaved} /> )} diff --git a/components/EefDashboardPage.tsx b/components/EefDashboardPage.tsx index 72962ce..3164a9b 100644 --- a/components/EefDashboardPage.tsx +++ b/components/EefDashboardPage.tsx @@ -166,24 +166,37 @@ const SupportingMetricChart: React.FC<{ ); const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { - const { selectedCycle, selectedKandang } = useFarm(); + const { selectedCycle, selectedKandang, updateCycle } = useFarm(); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); - const loadData = useCallback(async () => { - if (!selectedCycle) return; - setError(null); - try { - const kpiRows = await api.kpis.list({ cycle_id: selectedCycle.id }); - setRows(kpiRows); - setLastUpdated(new Date()); - } catch (err) { - setError(errorMessage(err)); - } - }, [selectedCycle]); + const loadData = useCallback( + async (isBackground = false) => { + if (!selectedCycle) return; + if (!isBackground) { + setError(null); + } + try { + const [kpiRows, freshCycle] = await Promise.all([ + api.kpis.list({ cycle_id: selectedCycle.id }), + api.cycles?.retrieve?.(selectedCycle.id)?.catch(() => null) ?? Promise.resolve(null), + ]); + if (freshCycle) { + updateCycle(selectedCycle.id, freshCycle); + } + setRows(kpiRows); + setLastUpdated(new Date()); + } catch (err) { + if (!isBackground) { + setError(errorMessage(err)); + } + } + }, + [selectedCycle?.id, updateCycle] + ); useEffect(() => { if (!selectedCycle) { @@ -191,24 +204,41 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) setLastUpdated(null); return; } - let cancelled = false; - (async () => { + void (async () => { setLoading(true); try { - await loadData(); + await loadData(false); } finally { - if (!cancelled) setLoading(false); + setLoading(false); } })(); - return () => { - cancelled = true; + + const interval = setInterval(() => { + if (document.visibilityState === 'visible') { + void loadData(true); + } + }, 60000); + + const handleFocusOrVisibility = () => { + if (document.visibilityState === 'visible') { + void loadData(true); + } }; - }, [selectedCycle, loadData]); + + document.addEventListener('visibilitychange', handleFocusOrVisibility); + window.addEventListener('focus', handleFocusOrVisibility); + + return () => { + clearInterval(interval); + document.removeEventListener('visibilitychange', handleFocusOrVisibility); + window.removeEventListener('focus', handleFocusOrVisibility); + }; + }, [selectedCycle?.id, loadData]); const handleRefresh = async () => { setRefreshing(true); try { - await loadData(); + await loadData(false); } finally { setRefreshing(false); } diff --git a/components/FcrDashboardPage.tsx b/components/FcrDashboardPage.tsx index f02b782..1f6daf9 100644 --- a/components/FcrDashboardPage.tsx +++ b/components/FcrDashboardPage.tsx @@ -157,7 +157,7 @@ const SupportingMetricChart: React.FC<{ ); const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { - const { selectedCycle, selectedKandang } = useFarm(); + const { selectedCycle, selectedKandang, updateCycle } = useFarm(); const [rows, setRows] = useState([]); const [weightRows, setWeightRows] = useState([]); const [loading, setLoading] = useState(false); @@ -165,21 +165,32 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); - const loadData = useCallback(async () => { - if (!selectedCycle) return; - setError(null); - try { - const [kpiRows, weightList] = await Promise.all([ - api.kpis.list({ cycle_id: selectedCycle.id }), - api.weights.list({ cycle_id: selectedCycle.id }), - ]); - setRows(kpiRows); - setWeightRows(weightList); - setLastUpdated(new Date()); - } catch (err) { - setError(errorMessage(err)); - } - }, [selectedCycle]); + const loadData = useCallback( + async (isBackground = false) => { + if (!selectedCycle) return; + if (!isBackground) { + setError(null); + } + try { + const [kpiRows, weightList, freshCycle] = await Promise.all([ + api.kpis.list({ cycle_id: selectedCycle.id }), + api.weights.list({ cycle_id: selectedCycle.id }), + api.cycles?.retrieve?.(selectedCycle.id)?.catch(() => null) ?? Promise.resolve(null), + ]); + if (freshCycle) { + updateCycle(selectedCycle.id, freshCycle); + } + setRows(kpiRows); + setWeightRows(weightList); + setLastUpdated(new Date()); + } catch (err) { + if (!isBackground) { + setError(errorMessage(err)); + } + } + }, + [selectedCycle?.id, updateCycle] + ); useEffect(() => { if (!selectedCycle) { @@ -188,24 +199,41 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) setLastUpdated(null); return; } - let cancelled = false; - (async () => { + void (async () => { setLoading(true); try { - await loadData(); + await loadData(false); } finally { - if (!cancelled) setLoading(false); + setLoading(false); } })(); - return () => { - cancelled = true; + + const interval = setInterval(() => { + if (document.visibilityState === 'visible') { + void loadData(true); + } + }, 60000); + + const handleFocusOrVisibility = () => { + if (document.visibilityState === 'visible') { + void loadData(true); + } }; - }, [selectedCycle, loadData]); + + document.addEventListener('visibilitychange', handleFocusOrVisibility); + window.addEventListener('focus', handleFocusOrVisibility); + + return () => { + clearInterval(interval); + document.removeEventListener('visibilitychange', handleFocusOrVisibility); + window.removeEventListener('focus', handleFocusOrVisibility); + }; + }, [selectedCycle?.id, loadData]); const handleRefresh = async () => { setRefreshing(true); try { - await loadData(); + await loadData(false); } finally { setRefreshing(false); } diff --git a/components/FeedSackCountingPage.tsx b/components/FeedSackCountingPage.tsx index 7ecd4ea..bf7de82 100644 --- a/components/FeedSackCountingPage.tsx +++ b/components/FeedSackCountingPage.tsx @@ -29,22 +29,35 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings const [notice, setNotice] = useState(null); const [showCyclePicker, setShowCyclePicker] = useState(false); - const reload = useCallback(async (cycleId: number) => { - setLoading(true); - setError(null); - try { - const [karungRows, manualList] = await Promise.all([ - api.karungs.list({ cycle_id: cycleId }), - api.manualInputs.list({ cycle_id: cycleId }), - ]); - setRows(karungRows); - setManualRows(manualList); - } catch (err) { - setError(errorMessage(err)); - } finally { - setLoading(false); - } - }, []); + const reload = useCallback( + async (cycleId: number, isBackground = false) => { + if (!isBackground) { + setLoading(true); + } + setError(null); + try { + const [karungRows, manualList, freshCycle] = await Promise.all([ + api.karungs.list({ cycle_id: cycleId }), + api.manualInputs.list({ cycle_id: cycleId }), + api.cycles?.retrieve?.(cycleId)?.catch(() => null) ?? Promise.resolve(null), + ]); + if (freshCycle) { + updateCycle(cycleId, freshCycle); + } + setRows(karungRows); + setManualRows(manualList); + } catch (err) { + if (!isBackground) { + setError(errorMessage(err)); + } + } finally { + if (!isBackground) { + setLoading(false); + } + } + }, + [updateCycle] + ); useEffect(() => { if (!selectedCycle) { @@ -52,8 +65,30 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings setManualRows([]); return; } - void reload(selectedCycle.id); - }, [selectedCycle, reload]); + const cycleId = selectedCycle.id; + void reload(cycleId, false); + + const interval = setInterval(() => { + if (document.visibilityState === 'visible') { + void reload(cycleId, true); + } + }, 60000); + + const handleFocusOrVisibility = () => { + if (document.visibilityState === 'visible') { + void reload(cycleId, true); + } + }; + + document.addEventListener('visibilitychange', handleFocusOrVisibility); + window.addEventListener('focus', handleFocusOrVisibility); + + return () => { + clearInterval(interval); + document.removeEventListener('visibilitychange', handleFocusOrVisibility); + window.removeEventListener('focus', handleFocusOrVisibility); + }; + }, [selectedCycle?.id, reload]); const sortedRows = useMemo(() => [...rows].sort((a, b) => a.date.localeCompare(b.date)), [rows]); const latest = sortedRows[sortedRows.length - 1] ?? null; diff --git a/components/IotPanelPage.tsx b/components/IotPanelPage.tsx index 8a28416..de19e12 100644 --- a/components/IotPanelPage.tsx +++ b/components/IotPanelPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { CartesianGrid, Line, @@ -69,7 +69,7 @@ type DaySummary = { }; const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { - const { selectedCycle, selectedKandang, selectedSite } = useFarm(); + const { selectedCycle, selectedKandang, selectedSite, updateCycle } = useFarm(); const [flocks, setFlocks] = useState([]); const [selectedFlockId, setSelectedFlockId] = useState(null); const [dates, setDates] = useState([]); @@ -118,6 +118,69 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { }; }, [selectedKandang]); + const reload = useCallback( + async (isBackground = false) => { + if (!selectedCycle || selectedFlockId == null) { + return; + } + if (!isBackground) { + setLoading(true); + } + if (!isBackground) { + setError(null); + } + try { + const [dateList, freshCycle] = await Promise.all([ + api.panelIot.dates({ + cycle_id: selectedCycle.id, + flock_id: selectedFlockId, + }), + api.cycles?.retrieve?.(selectedCycle.id)?.catch(() => null) ?? Promise.resolve(null), + ]); + if (freshCycle) { + updateCycle(selectedCycle.id, freshCycle); + } + const sortedDates = [...dateList].sort(); + setDates(sortedDates); + const cycleMin = selectedCycle.start_date; + const cycleMax = resolveCycleBoundDate(selectedCycle); + const fallbackDate = + todayIso() > cycleMax ? cycleMax : todayIso() < cycleMin ? cycleMin : todayIso(); + + let dateToFetch = selectedDate; + if (!dateToFetch) { + if (sortedDates.length > 0) { + dateToFetch = sortedDates[sortedDates.length - 1] ?? fallbackDate; + } else { + dateToFetch = fallbackDate; + } + setSelectedDate(dateToFetch); + } + + if (dateToFetch) { + const data = await api.panelIot.list({ + cycle_id: selectedCycle.id, + flock_id: selectedFlockId, + date: dateToFetch, + }); + const sorted = [...data].sort((a, b) => + (a.timestamp ?? a.date).localeCompare(b.timestamp ?? b.date) + ); + setDayRows(sorted); + } + } catch (err) { + if (!isBackground) { + setError(errorMessage(err)); + } + } finally { + if (!isBackground) { + setLoading(false); + } + } + }, + [selectedCycle, selectedFlockId, selectedDate, updateCycle] + ); + useEffect(() => { if (!selectedCycle || selectedFlockId == null) { setDates([]); @@ -159,7 +222,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { return () => { cancelled = true; }; - }, [selectedCycle, selectedFlockId]); + }, [selectedCycle?.id, selectedFlockId]); useEffect(() => { if (!selectedCycle || selectedFlockId == null || !selectedDate) { @@ -191,7 +254,33 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { return () => { cancelled = true; }; - }, [selectedCycle, selectedFlockId, selectedDate]); + }, [selectedCycle?.id, selectedFlockId, selectedDate]); + + useEffect(() => { + if (!selectedCycle || selectedFlockId == null) { + return; + } + const interval = setInterval(() => { + if (document.visibilityState === 'visible') { + void reload(true); + } + }, 60000); + + const handleFocusOrVisibility = () => { + if (document.visibilityState === 'visible') { + void reload(true); + } + }; + + document.addEventListener('visibilitychange', handleFocusOrVisibility); + window.addEventListener('focus', handleFocusOrVisibility); + + return () => { + clearInterval(interval); + document.removeEventListener('visibilitychange', handleFocusOrVisibility); + window.removeEventListener('focus', handleFocusOrVisibility); + }; + }, [selectedCycle?.id, selectedFlockId, reload]); const summary = useMemo(() => { if (dayRows.length === 0) return null; diff --git a/components/WeightMonitoringPage.tsx b/components/WeightMonitoringPage.tsx index df0973e..e051ac7 100644 --- a/components/WeightMonitoringPage.tsx +++ b/components/WeightMonitoringPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, 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'; @@ -18,7 +18,7 @@ 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 { selectedCycle, selectedSite, selectedKandang, updateCycle } = useFarm(); const [rows, setRows] = useState([]); const [latest, setLatest] = useState(null); const [loading, setLoading] = useState(false); @@ -29,21 +29,39 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings 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; + const reload = useCallback( + async (cycleId: number, isBackground = false) => { + if (!isBackground) { + setLoading(true); } - } catch (err) { - setError(errorMessage(err)); - } - }; + setError(null); + try { + const [list, freshCycle] = await Promise.all([ + api.weights.list({ cycle_id: cycleId }), + api.cycles?.retrieve?.(cycleId)?.catch(() => null) ?? Promise.resolve(null), + ]); + if (freshCycle) { + updateCycle(cycleId, freshCycle); + } + 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) { + if (!isBackground) { + setError(errorMessage(err)); + } + } finally { + if (!isBackground) { + setLoading(false); + } + } + }, + [updateCycle] + ); useEffect(() => { if (!selectedCycle) { @@ -52,20 +70,30 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings setSelectedDate(''); return; } - let cancelled = false; - (async () => { - setLoading(true); - try { - await reload(selectedCycle.id); - } finally { - if (!cancelled) setLoading(false); + const cycleId = selectedCycle.id; + void reload(cycleId, false); + + const interval = setInterval(() => { + if (document.visibilityState === 'visible') { + void reload(cycleId, true); + } + }, 60000); + + const handleFocusOrVisibility = () => { + if (document.visibilityState === 'visible') { + void reload(cycleId, true); } - })(); - return () => { - cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedCycle]); + + document.addEventListener('visibilitychange', handleFocusOrVisibility); + window.addEventListener('focus', handleFocusOrVisibility); + + return () => { + clearInterval(interval); + document.removeEventListener('visibilitychange', handleFocusOrVisibility); + window.removeEventListener('focus', handleFocusOrVisibility); + }; + }, [selectedCycle?.id, reload]); // 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 diff --git a/components/counting/DataManagementPage.tsx b/components/counting/DataManagementPage.tsx index a7f3f8e..05d1450 100644 --- a/components/counting/DataManagementPage.tsx +++ b/components/counting/DataManagementPage.tsx @@ -48,7 +48,7 @@ function mortalityAccuracy(manual: number, iot: number): number { } const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { - const { selectedCycle, selectedKandang, selectedSite } = useFarm(); + const { selectedCycle, selectedKandang, selectedSite, updateCycle } = useFarm(); const [kpis, setKpis] = useState([]); const [countings, setCountings] = useState([]); const [manualRows, setManualRows] = useState([]); @@ -61,24 +61,37 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } const isClosedCycle = !selectedCycle || resolveCycleStatus(selectedCycle) !== 'active'; - const loadData = useCallback(async (cycleId: number) => { - setLoading(true); - setError(null); - try { - const [k, c, m] = await Promise.all([ - api.kpis.list({ cycle_id: cycleId }), - api.countings.list({ cycle_id: cycleId }), - api.manualInputs.list({ cycle_id: cycleId }), - ]); - setKpis(k); - setCountings(c); - setManualRows(m); - } catch (err) { - setError(errorMessage(err)); - } finally { - setLoading(false); - } - }, []); + const loadData = useCallback( + async (cycleId: number, isBackground = false) => { + if (!isBackground) { + setLoading(true); + } + setError(null); + try { + const [k, c, m, freshCycle] = await Promise.all([ + api.kpis.list({ cycle_id: cycleId }), + api.countings.list({ cycle_id: cycleId }), + api.manualInputs.list({ cycle_id: cycleId }), + api.cycles?.retrieve?.(cycleId)?.catch(() => null) ?? Promise.resolve(null), + ]); + if (freshCycle) { + updateCycle(cycleId, freshCycle); + } + setKpis(k); + setCountings(c); + setManualRows(m); + } catch (err) { + if (!isBackground) { + setError(errorMessage(err)); + } + } finally { + if (!isBackground) { + setLoading(false); + } + } + }, + [updateCycle] + ); useEffect(() => { if (!selectedCycle) { @@ -87,8 +100,30 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } setManualRows([]); return; } - void loadData(selectedCycle.id); - }, [selectedCycle, loadData]); + const cycleId = selectedCycle.id; + void loadData(cycleId, false); + + const interval = setInterval(() => { + if (document.visibilityState === 'visible') { + void loadData(cycleId, true); + } + }, 60000); + + const handleFocusOrVisibility = () => { + if (document.visibilityState === 'visible') { + void loadData(cycleId, true); + } + }; + + document.addEventListener('visibilitychange', handleFocusOrVisibility); + window.addEventListener('focus', handleFocusOrVisibility); + + return () => { + clearInterval(interval); + document.removeEventListener('visibilitychange', handleFocusOrVisibility); + window.removeEventListener('focus', handleFocusOrVisibility); + }; + }, [selectedCycle?.id, loadData]); const handleSync = useCallback(async () => { if (!selectedCycle) return; diff --git a/components/dashboard/DashboardManualInputForm.tsx b/components/dashboard/DashboardManualInputForm.tsx index 92a5e94..4b75154 100644 --- a/components/dashboard/DashboardManualInputForm.tsx +++ b/components/dashboard/DashboardManualInputForm.tsx @@ -11,6 +11,7 @@ type Props = { cycleId: number; currentDay: number; readOnly?: boolean; + onSaved?: (savedRow: ManualInput) => void | Promise; }; type FormState = { @@ -52,7 +53,12 @@ function toForm(row: ManualInput): FormState { }; } -const DashboardManualInputForm: React.FC = ({ cycleId, currentDay, readOnly = false }) => { +const DashboardManualInputForm: React.FC = ({ + cycleId, + currentDay, + readOnly = false, + onSaved, +}) => { const [form, setForm] = useState(() => emptyForm(currentDay)); const [saved, setSaved] = useState(null); const [rows, setRows] = useState([]); @@ -138,6 +144,13 @@ const DashboardManualInputForm: React.FC = ({ cycleId, currentDay, readOn return [row, ...others]; }); setNotice('Input manual tersimpan.'); + if (onSaved) { + try { + await onSaved(row); + } catch { + // Non-blocking if parent refresh fails + } + } } catch (err) { setError(errorMessage(err)); } finally { diff --git a/components/dashboard/__tests__/DashboardManualInputForm.test.tsx b/components/dashboard/__tests__/DashboardManualInputForm.test.tsx index 7ed0847..b25c577 100644 --- a/components/dashboard/__tests__/DashboardManualInputForm.test.tsx +++ b/components/dashboard/__tests__/DashboardManualInputForm.test.tsx @@ -82,4 +82,23 @@ describe('DashboardManualInputForm', () => { expect(body).not.toHaveProperty('fcr_manual'); expect(body).not.toHaveProperty('eef_manual'); }); + + it('calls onSaved callback when manual input is successfully saved', async () => { + const onSavedMock = vi.fn(); + render(); + + expect(await screen.findByRole('heading', { name: /Input manual/i })).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText(/Mortalitas/i), { target: { value: '5' } }); + fireEvent.click(screen.getByRole('button', { name: /Simpan/i })); + + await waitFor(() => expect(createMock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(onSavedMock).toHaveBeenCalledTimes(1)); + expect(onSavedMock).toHaveBeenCalledWith( + expect.objectContaining({ + id: 1, + cycle: 4, + mortality_manual: 5, + }) + ); + }); }); diff --git a/services/apiClient.ts b/services/apiClient.ts index d00e703..2feca5f 100644 --- a/services/apiClient.ts +++ b/services/apiClient.ts @@ -191,6 +191,7 @@ export const api = { cycles: { list: async (query?: CycleFilter) => unwrapList(await get | Cycle[]>('/cycles/', query)), + retrieve: (id: number) => get(`/cycles/${id}/`), create: (body: CycleWrite) => post('/cycles/', body), update: (id: number, body: Partial) => patch(`/cycles/${id}/`, body), remove: (id: number) => del(`/cycles/${id}/`),