update all page to refresh immidiately without clicking sync button or refreshing the whole page
This commit is contained in:
1 parent
d5b0c12c40
commit
83a41fbbcb
15 files changed
+490
-151
No files matched your search
+2
-1
@@ -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).
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}/`),
|
||||
|
||||
Reference in new issue
Block a user