update karung related component to follow cutoff time

This commit is contained in:
Alberto-Audrix committed 2026-10-09 08:46:13 +07:00
1 parent 26c69b340d
commit 03c7dcb646
5 files changed
+84 -53

No files matched your search

+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+47 -32
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { api, ApiError, errorMessage } from '../services/apiClient.ts'; import { api, errorMessage } from '../services/apiClient.ts';
import type { import type {
ChickenCounting, ChickenCounting,
ChickenWeight, ChickenWeight,
@@ -52,7 +52,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
const [countings, setCountings] = useState<ChickenCounting[]>([]); const [countings, setCountings] = useState<ChickenCounting[]>([]);
const [manualInputs, setManualInputs] = useState<ManualInput[]>([]); const [manualInputs, setManualInputs] = useState<ManualInput[]>([]);
const [weights, setWeights] = useState<ChickenWeight[]>([]); const [weights, setWeights] = useState<ChickenWeight[]>([]);
const [karung, setKarung] = useState<Karung | null>(null); const [karungs, setKarungs] = useState<Karung[]>([]);
const [iotDayAggregates, setIotDayAggregates] = useState<EndCycleIotDayAggregate[]>([]); const [iotDayAggregates, setIotDayAggregates] = useState<EndCycleIotDayAggregate[]>([]);
const [reportOpen, setReportOpen] = useState(false); const [reportOpen, setReportOpen] = useState(false);
const [reportType, setReportType] = useState<DashboardReportType>('daily'); const [reportType, setReportType] = useState<DashboardReportType>('daily');
@@ -64,7 +64,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
setCountings([]); setCountings([]);
setManualInputs([]); setManualInputs([]);
setWeights([]); setWeights([]);
setKarung(null); setKarungs([]);
setIotDayAggregates([]); setIotDayAggregates([]);
return; return;
} }
@@ -78,19 +78,15 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
api.countings.sync({ cycle_id: cycleId }).catch(() => null), api.countings.sync({ cycle_id: cycleId }).catch(() => null),
api.weights.sync({ cycle_id: cycleId }).catch(() => null), api.weights.sync({ cycle_id: cycleId }).catch(() => null),
]); ]);
const [kpiRows, countingRows, manualRows, weightRows, freshCycle] = await Promise.all([ const [kpiRows, countingRows, manualRows, weightRows, karungRows, freshCycle] =
api.kpis.list({ cycle_id: cycleId }), await Promise.all([
api.countings.list({ cycle_id: cycleId }), api.kpis.list({ cycle_id: cycleId }),
api.manualInputs.list({ cycle_id: cycleId }), api.countings.list({ cycle_id: cycleId }),
api.weights.list({ cycle_id: cycleId }), api.manualInputs.list({ cycle_id: cycleId }),
api.cycles?.retrieve?.(cycleId)?.catch(() => null) ?? Promise.resolve(null), api.weights.list({ cycle_id: cycleId }),
]); api.karungs.list({ cycle_id: cycleId }).catch(() => []),
let latestKarung: Karung | null = null; api.cycles?.retrieve?.(cycleId)?.catch(() => null) ?? Promise.resolve(null),
try { ]);
latestKarung = await api.karungs.latest({ cycle_id: cycleId });
} catch (err) {
if (!(err instanceof ApiError && err.status === 404)) throw err;
}
if (freshCycle) { if (freshCycle) {
updateCycle(cycleId, freshCycle); updateCycle(cycleId, freshCycle);
} }
@@ -98,7 +94,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
setCountings(countingRows); setCountings(countingRows);
setManualInputs(manualRows); setManualInputs(manualRows);
setWeights(weightRows); setWeights(weightRows);
setKarung(latestKarung); setKarungs(karungRows);
// Best-effort IoT aggregates for end-cycle root cause (non-blocking on failure). // Best-effort IoT aggregates for end-cycle root cause (non-blocking on failure).
try { try {
@@ -180,10 +176,23 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
void reload(); void reload();
}, [fetchDashboardData, reload]); }, [fetchDashboardData, reload]);
const visibleThrough = selectedCycle?.visible_through_date;
const visibleKpis = useMemo(
() => throughVisibleDate(kpis, visibleThrough),
[kpis, visibleThrough]
);
const sortedKarungs = useMemo(
() => [...karungs].sort((a, b) => a.date.localeCompare(b.date)),
[karungs]
);
const visibleKarungs = useMemo(
() => throughVisibleDate(sortedKarungs, visibleThrough),
[sortedKarungs, visibleThrough]
);
const latestKarung = visibleKarungs[visibleKarungs.length - 1] ?? null;
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).
const visibleThrough = selectedCycle?.visible_through_date;
const visibleKpis = throughVisibleDate(kpis, visibleThrough);
const latest = visibleKpis[0] ?? null; const latest = visibleKpis[0] ?? null;
const anchorDate = latest?.date ?? null; const anchorDate = latest?.date ?? null;
@@ -285,7 +294,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
targetView: 'eefDashboard', targetView: 'eefDashboard',
}, },
]; ];
}, [kpis, weights, selectedCycle]); }, [visibleKpis, weights, selectedCycle, visibleThrough]);
const countingAccuracyWarning = useMemo(() => { const countingAccuracyWarning = useMemo(() => {
if (!selectedCycle) return null; if (!selectedCycle) return null;
@@ -299,8 +308,6 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
const insightContext = useMemo(() => { const insightContext = useMemo(() => {
if (selectedKandangId == null) return null; if (selectedKandangId == null) return null;
const visibleThrough = selectedCycle?.visible_through_date;
const visibleKpis = throughVisibleDate(kpis, visibleThrough);
const latest = visibleKpis[0] ?? null; const latest = visibleKpis[0] ?? null;
const anchorDate = latest?.date ?? null; const anchorDate = latest?.date ?? null;
const weightsThroughAnchor = anchorDate const weightsThroughAnchor = anchorDate
@@ -330,8 +337,10 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
? null ? null
: Math.round((100 - latest.chicken_life_percentage) * 100) / 100, : Math.round((100 - latest.chicken_life_percentage) * 100) / 100,
karung_dituang_hari_ini: karung_dituang_hari_ini:
latest != null ? latest.feed : (karung?.feed_use_today ?? karung?.out_today ?? null), latest != null
pakan_kumulatif_karung: karung?.feed_use_total ?? latest?.feed_total ?? null, ? latest.feed
: (latestKarung?.feed_use_today ?? latestKarung?.out_today ?? null),
pakan_kumulatif_karung: latestKarung?.feed_use_total ?? latest?.feed_total ?? null,
ayam_hidup_ekor: ayam_hidup_ekor:
latest != null ? Math.max(0, latest.chicken_life - (latest.harvest_total ?? 0)) : null, latest != null ? Math.max(0, latest.chicken_life - (latest.harvest_total ?? 0)) : null,
persen_hidup: latest?.chicken_life_percentage ?? null, persen_hidup: latest?.chicken_life_percentage ?? null,
@@ -343,12 +352,18 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
latest?.harvest_weight_total != null ? latest.harvest_weight_total / 1000 : null, latest?.harvest_weight_total != null ? latest.harvest_weight_total / 1000 : null,
uniformity_persen: weightOnAnchor?.uniformity ?? null, uniformity_persen: weightOnAnchor?.uniformity ?? null,
}); });
}, [selectedKandangId, selectedKandang, selectedCycle, kpis, weights, karung]); }, [
selectedKandangId,
selectedKandang,
selectedCycle,
visibleKpis,
weights,
visibleThrough,
latestKarung,
]);
const insightMetrics = useMemo(() => { const insightMetrics = useMemo(() => {
if (selectedKandangId == null) return null; if (selectedKandangId == null) return null;
const visibleThrough = selectedCycle?.visible_through_date;
const visibleKpis = throughVisibleDate(kpis, visibleThrough);
const latest = visibleKpis[0] ?? null; const latest = visibleKpis[0] ?? null;
const anchorDate = latest?.date ?? null; const anchorDate = latest?.date ?? null;
const weightsThroughAnchor = anchorDate const weightsThroughAnchor = anchorDate
@@ -360,17 +375,17 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
weights: weightsThroughAnchor, weights: weightsThroughAnchor,
currentDay: latest?.age ?? selectedCycle?.current_day ?? null, currentDay: latest?.age ?? selectedCycle?.current_day ?? null,
docInCount: selectedCycle?.doc_in_count ?? null, docInCount: selectedCycle?.doc_in_count ?? null,
pakanKumulatifFallback: karung?.feed_use_total ?? latest?.feed_total ?? null, pakanKumulatifFallback: latestKarung?.feed_use_total ?? latest?.feed_total ?? null,
}); });
}, [selectedKandangId, selectedCycle, kpis, weights, karung]); }, [selectedKandangId, selectedCycle, visibleKpis, weights, visibleThrough, latestKarung]);
if (!selectedCycle) return <EmptyFarm onSettings={() => setView('kandangSettings')} />; if (!selectedCycle) return <EmptyFarm onSettings={() => setView('kandangSettings')} />;
const feedTotal = karung?.feed_use_total ?? kpis[0]?.feed_total ?? 0; const feedTotal = latestKarung?.feed_use_total ?? visibleKpis[0]?.feed_total ?? 0;
const feedWeightKg = feedTotal * 50; const feedWeightKg = feedTotal * 50;
const feedInitialBalance = selectedCycle?.feed_initial_balance ?? 0; const feedInitialBalance = selectedCycle?.feed_initial_balance ?? 0;
const feedInTotal = (karung?.in_total ?? 0) + feedInitialBalance; const feedInTotal = (latestKarung?.in_total ?? 0) + feedInitialBalance;
const feedOutTotal = karung?.out_total ?? 0; const feedOutTotal = latestKarung?.out_total ?? 0;
const feedSaldo = feedInTotal - feedTotal - feedOutTotal; const feedSaldo = feedInTotal - feedTotal - feedOutTotal;
const feedSaldoColor = const feedSaldoColor =
feedSaldo > 0 ? 'text-green-600' : feedSaldo < 0 ? 'text-red-600' : 'text-gray-600'; feedSaldo > 0 ? 'text-green-600' : feedSaldo < 0 ? 'text-red-600' : 'text-gray-600';
+26 -16
View File
@@ -10,6 +10,7 @@ import {
resolveCycleStatus, resolveCycleStatus,
todayIso, todayIso,
} from '../utils/format.ts'; } from '../utils/format.ts';
import { throughVisibleDate } from '../utils/visibility.ts';
import { buildFeedSackInsightContext } from '../utils/insightContextBuilders.ts'; import { buildFeedSackInsightContext } from '../utils/insightContextBuilders.ts';
import FeedStats from './feedSack/FeedStats.tsx'; import FeedStats from './feedSack/FeedStats.tsx';
import FeedSaldoCard from './feedSack/FeedSaldoCard.tsx'; import FeedSaldoCard from './feedSack/FeedSaldoCard.tsx';
@@ -91,22 +92,30 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
}, [selectedCycle?.id, reload]); }, [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 sortedManualRows = useMemo( const sortedManualRows = useMemo(
() => [...manualRows].sort((a, b) => a.date.localeCompare(b.date)), () => [...manualRows].sort((a, b) => a.date.localeCompare(b.date)),
[manualRows] [manualRows]
); );
const visibleThrough = selectedCycle?.visible_through_date; const visibleThrough = selectedCycle?.visible_through_date;
// Display date follows the latest IoT snapshot; without any snapshot, fall back to today. const visibleRows = useMemo(
// Manual cards only use a same-date row — never fall back to an older day. () => throughVisibleDate(sortedRows, visibleThrough),
[sortedRows, visibleThrough]
);
const visibleManualRows = useMemo(
() => throughVisibleDate(sortedManualRows, visibleThrough),
[sortedManualRows, visibleThrough]
);
const latest = visibleRows[visibleRows.length - 1] ?? null;
// Display date follows the latest visible IoT snapshot; without any snapshot, fall back to cutoff date / today.
const displayDate = latest?.date ?? visibleThrough ?? todayIso(); const displayDate = latest?.date ?? visibleThrough ?? todayIso();
const latestManual = sortedManualRows.find((row) => row.date === displayDate) ?? null; const latestManual = visibleManualRows.find((row) => row.date === displayDate) ?? null;
const lastRecordedManual = visibleManualRows[visibleManualRows.length - 1] ?? null;
const currentDay = useMemo(() => { const currentDay = useMemo(() => {
if (!selectedCycle) return 0; if (!selectedCycle) return 0;
const boundDate = selectedCycle.end_date ?? selectedCycle.visible_through_date ?? todayIso(); const boundDate = selectedCycle.end_date ?? visibleThrough ?? todayIso();
return cycleDayForDate(selectedCycle.start_date, boundDate); return cycleDayForDate(selectedCycle.start_date, boundDate);
}, [selectedCycle]); }, [selectedCycle, visibleThrough]);
const handleSync = useCallback(async () => { const handleSync = useCallback(async () => {
if (!selectedCycle) return; if (!selectedCycle) return;
@@ -151,8 +160,8 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
const insightContext = useMemo(() => { const insightContext = useMemo(() => {
if (!selectedKandang || !selectedCycle) return null; if (!selectedKandang || !selectedCycle) return null;
const manualByDate = new Map(sortedManualRows.map((row) => [row.date, row])); const manualByDate = new Map(visibleManualRows.map((row) => [row.date, row]));
const dailyRows = sortedRows.map((row) => { const dailyRows = visibleRows.map((row) => {
const hari = cycleDayForDate(selectedCycle.start_date, row.date); const hari = cycleDayForDate(selectedCycle.start_date, row.date);
const manual = manualByDate.get(row.date); const manual = manualByDate.get(row.date);
return { return {
@@ -174,10 +183,10 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
total_karung_masuk_iot: latest?.in_total ?? null, total_karung_masuk_iot: latest?.in_total ?? null,
total_karung_dituang_iot: latest?.feed_use_total ?? null, total_karung_dituang_iot: latest?.feed_use_total ?? null,
total_karung_keluar_iot: latest?.out_total ?? null, total_karung_keluar_iot: latest?.out_total ?? null,
total_karung_masuk_manual: latestManual?.feed_in_manual_total ?? null, total_karung_masuk_manual: lastRecordedManual?.feed_in_manual_total ?? null,
total_karung_dituang_manual: latestManual?.feed_use_manual_total ?? null, total_karung_dituang_manual: lastRecordedManual?.feed_use_manual_total ?? null,
}); });
}, [selectedKandang, selectedCycle, sortedRows, sortedManualRows, latest, latestManual]); }, [selectedKandang, selectedCycle, visibleRows, visibleManualRows, latest, lastRecordedManual]);
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />; if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
@@ -321,7 +330,7 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
{selectedKandang && insightContext ? ( {selectedKandang && insightContext ? (
<PageAiInsight <PageAiInsight
topic="hitung_karung" topic="hitung_karung"
cacheKey={`pakan-k${selectedKandang.id}-c${selectedCycle.id}-day${currentDay}-rows${sortedRows.length}`} cacheKey={`pakan-k${selectedKandang.id}-c${selectedCycle.id}-day${currentDay}-rows${visibleRows.length}`}
contextData={insightContext} contextData={insightContext}
chartSpec={{ chartSpec={{
type: 'trend', type: 'trend',
@@ -399,6 +408,7 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
<FeedStats <FeedStats
latest={latest} latest={latest}
latestManual={latestManual} latestManual={latestManual}
lastRecordedManual={lastRecordedManual}
cycleStartDate={selectedCycle.start_date} cycleStartDate={selectedCycle.start_date}
displayDate={displayDate} displayDate={displayDate}
/> />
@@ -413,15 +423,15 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
{/* Tren harian */} {/* Tren harian */}
<FeedTrendChart <FeedTrendChart
rows={sortedRows} rows={visibleRows}
cycleStartDate={selectedCycle.start_date} cycleStartDate={selectedCycle.start_date}
currentDay={currentDay} currentDay={currentDay}
/> />
{/* Rincian harian */} {/* Rincian harian */}
<FeedDailySummaryTable <FeedDailySummaryTable
rows={sortedRows} rows={visibleRows}
manualRows={manualRows} manualRows={visibleManualRows}
cycleStartDate={selectedCycle.start_date} cycleStartDate={selectedCycle.start_date}
currentDay={currentDay} currentDay={currentDay}
/> />
+8 -4
View File
@@ -30,6 +30,7 @@ const StatItem: React.FC<{
interface FeedStatsProps { interface FeedStatsProps {
latest: Karung | null; latest: Karung | null;
latestManual: ManualInput | null; latestManual: ManualInput | null;
lastRecordedManual?: ManualInput | null;
cycleStartDate: string; cycleStartDate: string;
displayDate: string; displayDate: string;
} }
@@ -37,6 +38,7 @@ interface FeedStatsProps {
const FeedStats: React.FC<FeedStatsProps> = ({ const FeedStats: React.FC<FeedStatsProps> = ({
latest, latest,
latestManual, latestManual,
lastRecordedManual,
cycleStartDate, cycleStartDate,
displayDate, displayDate,
}) => { }) => {
@@ -61,6 +63,8 @@ const FeedStats: React.FC<FeedStatsProps> = ({
return `Total (${start} - ${end})`; return `Total (${start} - ${end})`;
}, [cycleStartDate, displayDate]); }, [cycleStartDate, displayDate]);
const cumulativeManual = lastRecordedManual ?? latestManual;
const columns = useMemo( const columns = useMemo(
() => [ () => [
{ {
@@ -71,7 +75,7 @@ const FeedStats: React.FC<FeedStatsProps> = ({
iotToday: latest?.in_today ?? 0, iotToday: latest?.in_today ?? 0,
iotTotal: latest?.in_total ?? 0, iotTotal: latest?.in_total ?? 0,
manualToday: latestManual?.feed_in_manual ?? 0, manualToday: latestManual?.feed_in_manual ?? 0,
manualTotal: latestManual?.feed_in_manual_total ?? 0, manualTotal: cumulativeManual?.feed_in_manual_total ?? 0,
}, },
{ {
key: 'dituang', key: 'dituang',
@@ -81,7 +85,7 @@ const FeedStats: React.FC<FeedStatsProps> = ({
iotToday: latest?.feed_use_today ?? 0, iotToday: latest?.feed_use_today ?? 0,
iotTotal: latest?.feed_use_total ?? 0, iotTotal: latest?.feed_use_total ?? 0,
manualToday: latestManual?.feed_use_manual ?? 0, manualToday: latestManual?.feed_use_manual ?? 0,
manualTotal: latestManual?.feed_use_manual_total ?? 0, manualTotal: cumulativeManual?.feed_use_manual_total ?? 0,
}, },
{ {
key: 'keluar', key: 'keluar',
@@ -91,10 +95,10 @@ const FeedStats: React.FC<FeedStatsProps> = ({
iotToday: latest?.out_today ?? 0, iotToday: latest?.out_today ?? 0,
iotTotal: latest?.out_total ?? 0, iotTotal: latest?.out_total ?? 0,
manualToday: latestManual?.feed_out_manual ?? 0, manualToday: latestManual?.feed_out_manual ?? 0,
manualTotal: latestManual?.feed_out_manual_total ?? 0, manualTotal: cumulativeManual?.feed_out_manual_total ?? 0,
}, },
], ],
[latest, latestManual] [latest, latestManual, cumulativeManual]
); );
return ( return (
+2 -1
View File
@@ -22,7 +22,8 @@ COPY backend/ .
COPY docker/entrypoint-api.sh docker/entrypoint-cron.sh docker/karung_sync_loop.sh docker/edge_sync_loop.sh /app/ COPY docker/entrypoint-api.sh docker/entrypoint-cron.sh docker/karung_sync_loop.sh docker/edge_sync_loop.sh /app/
COPY docker/crontab /etc/cron.d/newdashboard COPY docker/crontab /etc/cron.d/newdashboard
RUN chmod +x /app/entrypoint-api.sh /app/entrypoint-cron.sh /app/karung_sync_loop.sh /app/edge_sync_loop.sh \ RUN sed -i 's/\r$//' /app/*.sh \
&& chmod +x /app/entrypoint-api.sh /app/entrypoint-cron.sh /app/karung_sync_loop.sh /app/edge_sync_loop.sh \
&& mkdir -p /app/data /app/media /app/staticfiles \ && mkdir -p /app/data /app/media /app/staticfiles \
&& chmod 0644 /etc/cron.d/newdashboard \ && chmod 0644 /etc/cron.d/newdashboard \
&& crontab /etc/cron.d/newdashboard && crontab /etc/cron.d/newdashboard