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
docs/
.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)
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}
@@ -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,
@@ -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).
+8
View File
@@ -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")
+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 type {
ChickenCounting,
@@ -44,7 +44,8 @@ function chronological<T>(rows: T[]): T[] {
}
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 [error, setError] = useState<string | null>(null);
const [kpis, setKpis] = useState<KPI[]>([]);
@@ -56,27 +57,29 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
const [reportOpen, setReportOpen] = useState(false);
const [reportType, setReportType] = useState<DashboardReportType>('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<Props> = ({ 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<Props> = ({ 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<Props> = ({ setView }) => {
cycleId={selectedCycle.id}
currentDay={selectedCycle.current_day}
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 { selectedCycle, selectedKandang } = useFarm();
const { selectedCycle, selectedKandang, updateCycle } = useFarm();
const [rows, setRows] = useState<KPI[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(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);
}
+52 -24
View File
@@ -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<KPI[]>([]);
const [weightRows, setWeightRows] = useState<ChickenWeight[]>([]);
const [loading, setLoading] = useState(false);
@@ -165,21 +165,32 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(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);
}
+53 -18
View File
@@ -29,22 +29,35 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
const [notice, setNotice] = useState<string | null>(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;
+93 -4
View File
@@ -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<Flock[]>([]);
const [selectedFlockId, setSelectedFlockId] = useState<number | null>(null);
const [dates, setDates] = useState<string[]>([]);
@@ -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<DaySummary | 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 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<ChickenWeight[]>([]);
const [latest, setLatest] = useState<LatestAvg | null>(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
+56 -21
View File
@@ -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<KPI[]>([]);
const [countings, setCountings] = useState<ChickenCounting[]>([]);
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
@@ -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;
@@ -11,6 +11,7 @@ type Props = {
cycleId: number;
currentDay: number;
readOnly?: boolean;
onSaved?: (savedRow: ManualInput) => void | Promise<void>;
};
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 [saved, setSaved] = useState<ManualInput | null>(null);
const [rows, setRows] = useState<ManualInput[]>([]);
@@ -138,6 +144,13 @@ const DashboardManualInputForm: React.FC<Props> = ({ 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 {
@@ -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(<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: {
list: async (query?: CycleFilter) =>
unwrapList(await get<Paginated<Cycle> | Cycle[]>('/cycles/', query)),
retrieve: (id: number) => get<Cycle>(`/cycles/${id}/`),
create: (body: CycleWrite) => post<Cycle>('/cycles/', body),
update: (id: number, body: Partial<CycleWrite>) => patch<Cycle>(`/cycles/${id}/`, body),
remove: (id: number) => del(`/cycles/${id}/`),