update all page to refresh immidiately without clicking sync button or refreshing the whole page

This commit is contained in:
Alberto-Audrix committed 2026-10-07 11:17:44 +07:00
1 parent d5b0c12c40
commit 83a41fbbcb
15 files changed
+490 -151

No files matched your search

+2 -1
View File
@@ -10,4 +10,5 @@ __pycache__
docker-compose.override.yml docker-compose.override.yml
docs/ docs/
.superpowers/ .superpowers/
ai-insight/chroma_db/ ai-insight/chroma_db/
backend/logs/rag_trace.jsonl
@@ -255,6 +255,11 @@ def sync_chicken_counting_for_cycle(
obj.save(update_fields=update_fields) obj.save(update_fields=update_fields)
upserted.append(day) 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} return {"dates": upserted, "upserted": len(upserted), "skipped": False}
@@ -329,4 +334,9 @@ def sync_chicken_weight_for_cycle(
upserted.append(day) upserted.append(day)
prev_weight = average_weight 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} return {"dates": upserted, "upserted": len(upserted), "skipped": False}
@@ -326,6 +326,9 @@ def sync_karung_for_cycle(
feed_sack, summary = request_karung(cycle, section=section) feed_sack, summary = request_karung(cycle, section=section)
history_dates = backfill_karung_from_combined(cycle) history_dates = backfill_karung_from_combined(cycle)
recompute_feed_sack_totals(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() feed_sack.refresh_from_db()
summary = { summary = {
**summary, **summary,
@@ -70,6 +70,7 @@ def refresh_kpi_source_fields(cycle: Cycle, *, through: date | None = None) -> i
cc = countings.get(day) cc = countings.get(day)
cw = weights.get(day) cw = weights.get(day)
fs = feeds.get(day) fs = feeds.get(day)
if not all([manual, cc, cw, fs]): if not all([manual, cc, cw, fs]):
# Still accumulate harvest age sum from manual-only days so later # Still accumulate harvest age sum from manual-only days so later
# complete days keep a correct running Σ(age×harvest). # complete days keep a correct running Σ(age×harvest).
+8
View File
@@ -74,6 +74,9 @@ class ManualInputViewSet(CycleScopedViewSet):
queryset = ManualInput.objects.select_related("cycle").all() queryset = ManualInput.objects.select_related("cycle").all()
serializer_class = ManualInputSerializer serializer_class = ManualInputSerializer
def applies_visibility_filter(self) -> bool:
return False
class ChickenCountingViewSet(CycleScopedViewSet): class ChickenCountingViewSet(CycleScopedViewSet):
"""Chicken counting + mortality IoT — not subject to dashboard publish cutoff.""" """Chicken counting + mortality IoT — not subject to dashboard publish cutoff."""
@@ -166,9 +169,14 @@ class ChickenWeightViewSet(CycleScopedViewSet):
class FeedSacksViewSet(CycleScopedViewSet): class FeedSacksViewSet(CycleScopedViewSet):
"""Feed sacks (karung masuk & karung dituang) — not subject to dashboard publish cutoff."""
queryset = FeedSacks.objects.select_related("cycle").all() queryset = FeedSacks.objects.select_related("cycle").all()
serializer_class = FeedSacksSerializer serializer_class = FeedSacksSerializer
def applies_visibility_filter(self) -> bool:
return False
@action(detail=False, methods=["get"], url_path="latest") @action(detail=False, methods=["get"], url_path="latest")
def latest(self, request): def latest(self, request):
cycle_id = request.query_params.get("cycle_id") cycle_id = request.query_params.get("cycle_id")
+72 -34
View File
@@ -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 { api, ApiError, errorMessage } from '../services/apiClient.ts';
import type { import type {
ChickenCounting, ChickenCounting,
@@ -44,7 +44,8 @@ function chronological<T>(rows: T[]): T[] {
} }
const Dashboard: React.FC<Props> = ({ setView }) => { const Dashboard: React.FC<Props> = ({ setView }) => {
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm(); const { selectedCycle, selectedKandang, selectedSite, selectedKandangId, reload, updateCycle } =
useFarm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [kpis, setKpis] = useState<KPI[]>([]); const [kpis, setKpis] = useState<KPI[]>([]);
@@ -56,27 +57,29 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
const [reportOpen, setReportOpen] = useState(false); const [reportOpen, setReportOpen] = useState(false);
const [reportType, setReportType] = useState<DashboardReportType>('daily'); const [reportType, setReportType] = useState<DashboardReportType>('daily');
useEffect(() => { const fetchDashboardData = useCallback(
if (!selectedCycle) { async (isBackground = false) => {
setKpis([]); if (!selectedCycle) {
setCountings([]); setKpis([]);
setManualInputs([]); setCountings([]);
setWeights([]); setManualInputs([]);
setKarung(null); setWeights([]);
setIotDayAggregates([]); setKarung(null);
return; setIotDayAggregates([]);
} return;
const cycleId = selectedCycle.id; }
let cancelled = false; const cycleId = selectedCycle.id;
(async () => { if (!isBackground) {
setLoading(true); setLoading(true);
}
setError(null); setError(null);
try { 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.kpis.list({ cycle_id: cycleId }),
api.countings.list({ cycle_id: cycleId }), api.countings.list({ cycle_id: cycleId }),
api.manualInputs.list({ cycle_id: cycleId }), api.manualInputs.list({ cycle_id: cycleId }),
api.weights.list({ cycle_id: cycleId }), api.weights.list({ cycle_id: cycleId }),
api.cycles?.retrieve?.(cycleId)?.catch(() => null) ?? Promise.resolve(null),
]); ]);
let latestKarung: Karung | null = null; let latestKarung: Karung | null = null;
try { try {
@@ -84,7 +87,9 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
} catch (err) { } catch (err) {
if (!(err instanceof ApiError && err.status === 404)) throw err; if (!(err instanceof ApiError && err.status === 404)) throw err;
} }
if (cancelled) return; if (freshCycle) {
updateCycle(cycleId, freshCycle);
}
setKpis(kpiRows); setKpis(kpiRows);
setCountings(countingRows); setCountings(countingRows);
setManualInputs(manualRows); setManualInputs(manualRows);
@@ -115,29 +120,61 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
); );
iotRows = batches.flat(); iotRows = batches.flat();
} }
if (!cancelled) { setIotDayAggregates(
setIotDayAggregates( buildIotDayAggregates({
buildIotDayAggregates({ rows: iotRows,
rows: iotRows, cycleStartDate: selectedCycle.start_date,
cycleStartDate: selectedCycle.start_date, getTempTarget: getTempStandardByDay,
getTempTarget: getTempStandardByDay, })
}) );
);
}
} }
} catch { } catch {
if (!cancelled) setIotDayAggregates([]); setIotDayAggregates([]);
} }
} catch (err) { } catch (err) {
if (!cancelled) setError(errorMessage(err)); if (!isBackground) {
setError(errorMessage(err));
}
} finally { } 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(() => { const cards: KpiCardModel[] = useMemo(() => {
// All cards share the latest complete KPI day (not calendar current_day / live IoT). // All cards share the latest complete KPI day (not calendar current_day / live IoT).
@@ -475,6 +512,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
cycleId={selectedCycle.id} cycleId={selectedCycle.id}
currentDay={selectedCycle.current_day} currentDay={selectedCycle.current_day}
readOnly={cycleStatus !== 'active'} readOnly={cycleStatus !== 'active'}
onSaved={handleManualInputSaved}
/> />
</> </>
)} )}
+50 -20
View File
@@ -166,24 +166,37 @@ const SupportingMetricChart: React.FC<{
); );
const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle, selectedKandang } = useFarm(); const { selectedCycle, selectedKandang, updateCycle } = useFarm();
const [rows, setRows] = useState<KPI[]>([]); const [rows, setRows] = useState<KPI[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null); const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const loadData = useCallback(async () => { const loadData = useCallback(
if (!selectedCycle) return; async (isBackground = false) => {
setError(null); if (!selectedCycle) return;
try { if (!isBackground) {
const kpiRows = await api.kpis.list({ cycle_id: selectedCycle.id }); setError(null);
setRows(kpiRows); }
setLastUpdated(new Date()); try {
} catch (err) { const [kpiRows, freshCycle] = await Promise.all([
setError(errorMessage(err)); api.kpis.list({ cycle_id: selectedCycle.id }),
} api.cycles?.retrieve?.(selectedCycle.id)?.catch(() => null) ?? Promise.resolve(null),
}, [selectedCycle]); ]);
if (freshCycle) {
updateCycle(selectedCycle.id, freshCycle);
}
setRows(kpiRows);
setLastUpdated(new Date());
} catch (err) {
if (!isBackground) {
setError(errorMessage(err));
}
}
},
[selectedCycle?.id, updateCycle]
);
useEffect(() => { useEffect(() => {
if (!selectedCycle) { if (!selectedCycle) {
@@ -191,24 +204,41 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
setLastUpdated(null); setLastUpdated(null);
return; return;
} }
let cancelled = false; void (async () => {
(async () => {
setLoading(true); setLoading(true);
try { try {
await loadData(); await loadData(false);
} finally { } 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 () => { const handleRefresh = async () => {
setRefreshing(true); setRefreshing(true);
try { try {
await loadData(); await loadData(false);
} finally { } finally {
setRefreshing(false); setRefreshing(false);
} }
+52 -24
View File
@@ -157,7 +157,7 @@ const SupportingMetricChart: React.FC<{
); );
const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle, selectedKandang } = useFarm(); const { selectedCycle, selectedKandang, updateCycle } = useFarm();
const [rows, setRows] = useState<KPI[]>([]); const [rows, setRows] = useState<KPI[]>([]);
const [weightRows, setWeightRows] = useState<ChickenWeight[]>([]); const [weightRows, setWeightRows] = useState<ChickenWeight[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -165,21 +165,32 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null); const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const loadData = useCallback(async () => { const loadData = useCallback(
if (!selectedCycle) return; async (isBackground = false) => {
setError(null); if (!selectedCycle) return;
try { if (!isBackground) {
const [kpiRows, weightList] = await Promise.all([ setError(null);
api.kpis.list({ cycle_id: selectedCycle.id }), }
api.weights.list({ cycle_id: selectedCycle.id }), try {
]); const [kpiRows, weightList, freshCycle] = await Promise.all([
setRows(kpiRows); api.kpis.list({ cycle_id: selectedCycle.id }),
setWeightRows(weightList); api.weights.list({ cycle_id: selectedCycle.id }),
setLastUpdated(new Date()); api.cycles?.retrieve?.(selectedCycle.id)?.catch(() => null) ?? Promise.resolve(null),
} catch (err) { ]);
setError(errorMessage(err)); if (freshCycle) {
} updateCycle(selectedCycle.id, freshCycle);
}, [selectedCycle]); }
setRows(kpiRows);
setWeightRows(weightList);
setLastUpdated(new Date());
} catch (err) {
if (!isBackground) {
setError(errorMessage(err));
}
}
},
[selectedCycle?.id, updateCycle]
);
useEffect(() => { useEffect(() => {
if (!selectedCycle) { if (!selectedCycle) {
@@ -188,24 +199,41 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
setLastUpdated(null); setLastUpdated(null);
return; return;
} }
let cancelled = false; void (async () => {
(async () => {
setLoading(true); setLoading(true);
try { try {
await loadData(); await loadData(false);
} finally { } 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 () => { const handleRefresh = async () => {
setRefreshing(true); setRefreshing(true);
try { try {
await loadData(); await loadData(false);
} finally { } finally {
setRefreshing(false); setRefreshing(false);
} }
+53 -18
View File
@@ -29,22 +29,35 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [showCyclePicker, setShowCyclePicker] = useState(false); const [showCyclePicker, setShowCyclePicker] = useState(false);
const reload = useCallback(async (cycleId: number) => { const reload = useCallback(
setLoading(true); async (cycleId: number, isBackground = false) => {
setError(null); if (!isBackground) {
try { setLoading(true);
const [karungRows, manualList] = await Promise.all([ }
api.karungs.list({ cycle_id: cycleId }), setError(null);
api.manualInputs.list({ cycle_id: cycleId }), try {
]); const [karungRows, manualList, freshCycle] = await Promise.all([
setRows(karungRows); api.karungs.list({ cycle_id: cycleId }),
setManualRows(manualList); api.manualInputs.list({ cycle_id: cycleId }),
} catch (err) { api.cycles?.retrieve?.(cycleId)?.catch(() => null) ?? Promise.resolve(null),
setError(errorMessage(err)); ]);
} finally { if (freshCycle) {
setLoading(false); updateCycle(cycleId, freshCycle);
} }
}, []); setRows(karungRows);
setManualRows(manualList);
} catch (err) {
if (!isBackground) {
setError(errorMessage(err));
}
} finally {
if (!isBackground) {
setLoading(false);
}
}
},
[updateCycle]
);
useEffect(() => { useEffect(() => {
if (!selectedCycle) { if (!selectedCycle) {
@@ -52,8 +65,30 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
setManualRows([]); setManualRows([]);
return; return;
} }
void reload(selectedCycle.id); const cycleId = selectedCycle.id;
}, [selectedCycle, reload]); 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 sortedRows = useMemo(() => [...rows].sort((a, b) => a.date.localeCompare(b.date)), [rows]);
const latest = sortedRows[sortedRows.length - 1] ?? null; const latest = sortedRows[sortedRows.length - 1] ?? null;
+93 -4
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
CartesianGrid, CartesianGrid,
Line, Line,
@@ -69,7 +69,7 @@ type DaySummary = {
}; };
const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle, selectedKandang, selectedSite } = useFarm(); const { selectedCycle, selectedKandang, selectedSite, updateCycle } = useFarm();
const [flocks, setFlocks] = useState<Flock[]>([]); const [flocks, setFlocks] = useState<Flock[]>([]);
const [selectedFlockId, setSelectedFlockId] = useState<number | null>(null); const [selectedFlockId, setSelectedFlockId] = useState<number | null>(null);
const [dates, setDates] = useState<string[]>([]); const [dates, setDates] = useState<string[]>([]);
@@ -118,6 +118,69 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
}; };
}, [selectedKandang]); }, [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(() => { useEffect(() => {
if (!selectedCycle || selectedFlockId == null) { if (!selectedCycle || selectedFlockId == null) {
setDates([]); setDates([]);
@@ -159,7 +222,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [selectedCycle, selectedFlockId]); }, [selectedCycle?.id, selectedFlockId]);
useEffect(() => { useEffect(() => {
if (!selectedCycle || selectedFlockId == null || !selectedDate) { if (!selectedCycle || selectedFlockId == null || !selectedDate) {
@@ -191,7 +254,33 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
return () => { return () => {
cancelled = true; 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<DaySummary | null>(() => { const summary = useMemo<DaySummary | null>(() => {
if (dayRows.length === 0) return null; if (dayRows.length === 0) return null;
+56 -28
View File
@@ -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 { api, ApiError, errorMessage } from '../services/apiClient.ts';
import type { ChickenWeight, LatestAvg } from '../types/api.ts'; import type { ChickenWeight, LatestAvg } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx'; import { useFarm } from '../context/FarmContext.tsx';
@@ -18,7 +18,7 @@ import DailyDetailPanel from './weight/DailyDetailPanel.tsx';
import { PageAiInsight } from './shared/PageAiInsight.tsx'; import { PageAiInsight } from './shared/PageAiInsight.tsx';
const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle, selectedSite, selectedKandang } = useFarm(); const { selectedCycle, selectedSite, selectedKandang, updateCycle } = useFarm();
const [rows, setRows] = useState<ChickenWeight[]>([]); const [rows, setRows] = useState<ChickenWeight[]>([]);
const [latest, setLatest] = useState<LatestAvg | null>(null); const [latest, setLatest] = useState<LatestAvg | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -29,21 +29,39 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
const isClosedCycle = !selectedCycle || resolveCycleStatus(selectedCycle) !== 'active'; const isClosedCycle = !selectedCycle || resolveCycleStatus(selectedCycle) !== 'active';
const reload = async (cycleId: number) => { const reload = useCallback(
setError(null); async (cycleId: number, isBackground = false) => {
try { if (!isBackground) {
const list = await api.weights.list({ cycle_id: cycleId }); setLoading(true);
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(null);
setError(errorMessage(err)); 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(() => { useEffect(() => {
if (!selectedCycle) { if (!selectedCycle) {
@@ -52,20 +70,30 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
setSelectedDate(''); setSelectedDate('');
return; return;
} }
let cancelled = false; const cycleId = selectedCycle.id;
(async () => { void reload(cycleId, false);
setLoading(true);
try { const interval = setInterval(() => {
await reload(selectedCycle.id); if (document.visibilityState === 'visible') {
} finally { void reload(cycleId, true);
if (!cancelled) setLoading(false); }
}, 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 // 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 // line does not drop to zero at the end of a cycle, mirroring the old
+56 -21
View File
@@ -48,7 +48,7 @@ function mortalityAccuracy(manual: number, iot: number): number {
} }
const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle, selectedKandang, selectedSite } = useFarm(); const { selectedCycle, selectedKandang, selectedSite, updateCycle } = useFarm();
const [kpis, setKpis] = useState<KPI[]>([]); const [kpis, setKpis] = useState<KPI[]>([]);
const [countings, setCountings] = useState<ChickenCounting[]>([]); const [countings, setCountings] = useState<ChickenCounting[]>([]);
const [manualRows, setManualRows] = useState<ManualInput[]>([]); const [manualRows, setManualRows] = useState<ManualInput[]>([]);
@@ -61,24 +61,37 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
const isClosedCycle = !selectedCycle || resolveCycleStatus(selectedCycle) !== 'active'; const isClosedCycle = !selectedCycle || resolveCycleStatus(selectedCycle) !== 'active';
const loadData = useCallback(async (cycleId: number) => { const loadData = useCallback(
setLoading(true); async (cycleId: number, isBackground = false) => {
setError(null); if (!isBackground) {
try { setLoading(true);
const [k, c, m] = await Promise.all([ }
api.kpis.list({ cycle_id: cycleId }), setError(null);
api.countings.list({ cycle_id: cycleId }), try {
api.manualInputs.list({ cycle_id: cycleId }), const [k, c, m, freshCycle] = await Promise.all([
]); api.kpis.list({ cycle_id: cycleId }),
setKpis(k); api.countings.list({ cycle_id: cycleId }),
setCountings(c); api.manualInputs.list({ cycle_id: cycleId }),
setManualRows(m); api.cycles?.retrieve?.(cycleId)?.catch(() => null) ?? Promise.resolve(null),
} catch (err) { ]);
setError(errorMessage(err)); if (freshCycle) {
} finally { updateCycle(cycleId, freshCycle);
setLoading(false); }
} setKpis(k);
}, []); setCountings(c);
setManualRows(m);
} catch (err) {
if (!isBackground) {
setError(errorMessage(err));
}
} finally {
if (!isBackground) {
setLoading(false);
}
}
},
[updateCycle]
);
useEffect(() => { useEffect(() => {
if (!selectedCycle) { if (!selectedCycle) {
@@ -87,8 +100,30 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
setManualRows([]); setManualRows([]);
return; return;
} }
void loadData(selectedCycle.id); const cycleId = selectedCycle.id;
}, [selectedCycle, loadData]); 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 () => { const handleSync = useCallback(async () => {
if (!selectedCycle) return; if (!selectedCycle) return;
@@ -11,6 +11,7 @@ type Props = {
cycleId: number; cycleId: number;
currentDay: number; currentDay: number;
readOnly?: boolean; readOnly?: boolean;
onSaved?: (savedRow: ManualInput) => void | Promise<void>;
}; };
type FormState = { type FormState = {
@@ -52,7 +53,12 @@ function toForm(row: ManualInput): FormState {
}; };
} }
const DashboardManualInputForm: React.FC<Props> = ({ cycleId, currentDay, readOnly = false }) => { const DashboardManualInputForm: React.FC<Props> = ({
cycleId,
currentDay,
readOnly = false,
onSaved,
}) => {
const [form, setForm] = useState<FormState>(() => emptyForm(currentDay)); const [form, setForm] = useState<FormState>(() => emptyForm(currentDay));
const [saved, setSaved] = useState<ManualInput | null>(null); const [saved, setSaved] = useState<ManualInput | null>(null);
const [rows, setRows] = useState<ManualInput[]>([]); const [rows, setRows] = useState<ManualInput[]>([]);
@@ -138,6 +144,13 @@ const DashboardManualInputForm: React.FC<Props> = ({ cycleId, currentDay, readOn
return [row, ...others]; return [row, ...others];
}); });
setNotice('Input manual tersimpan.'); setNotice('Input manual tersimpan.');
if (onSaved) {
try {
await onSaved(row);
} catch {
// Non-blocking if parent refresh fails
}
}
} catch (err) { } catch (err) {
setError(errorMessage(err)); setError(errorMessage(err));
} finally { } finally {
@@ -82,4 +82,23 @@ describe('DashboardManualInputForm', () => {
expect(body).not.toHaveProperty('fcr_manual'); expect(body).not.toHaveProperty('fcr_manual');
expect(body).not.toHaveProperty('eef_manual'); expect(body).not.toHaveProperty('eef_manual');
}); });
it('calls onSaved callback when manual input is successfully saved', async () => {
const onSavedMock = vi.fn();
render(<DashboardManualInputForm cycleId={4} currentDay={10} onSaved={onSavedMock} />);
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,
})
);
});
}); });
+1
View File
@@ -191,6 +191,7 @@ export const api = {
cycles: { cycles: {
list: async (query?: CycleFilter) => list: async (query?: CycleFilter) =>
unwrapList(await get<Paginated<Cycle> | Cycle[]>('/cycles/', query)), unwrapList(await get<Paginated<Cycle> | Cycle[]>('/cycles/', query)),
retrieve: (id: number) => get<Cycle>(`/cycles/${id}/`),
create: (body: CycleWrite) => post<Cycle>('/cycles/', body), create: (body: CycleWrite) => post<Cycle>('/cycles/', body),
update: (id: number, body: Partial<CycleWrite>) => patch<Cycle>(`/cycles/${id}/`, body), update: (id: number, body: Partial<CycleWrite>) => patch<Cycle>(`/cycles/${id}/`, body),
remove: (id: number) => del(`/cycles/${id}/`), remove: (id: number) => del(`/cycles/${id}/`),