diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index 3a5c8da..28fe9c9 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -1,5 +1,5 @@ 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 { ChickenCounting, ChickenWeight, @@ -52,7 +52,7 @@ const Dashboard: React.FC = ({ setView }) => { const [countings, setCountings] = useState([]); const [manualInputs, setManualInputs] = useState([]); const [weights, setWeights] = useState([]); - const [karung, setKarung] = useState(null); + const [karungs, setKarungs] = useState([]); const [iotDayAggregates, setIotDayAggregates] = useState([]); const [reportOpen, setReportOpen] = useState(false); const [reportType, setReportType] = useState('daily'); @@ -64,7 +64,7 @@ const Dashboard: React.FC = ({ setView }) => { setCountings([]); setManualInputs([]); setWeights([]); - setKarung(null); + setKarungs([]); setIotDayAggregates([]); return; } @@ -78,19 +78,15 @@ const Dashboard: React.FC = ({ setView }) => { api.countings.sync({ cycle_id: cycleId }).catch(() => null), api.weights.sync({ cycle_id: cycleId }).catch(() => null), ]); - 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 { - latestKarung = await api.karungs.latest({ cycle_id: cycleId }); - } catch (err) { - if (!(err instanceof ApiError && err.status === 404)) throw err; - } + const [kpiRows, countingRows, manualRows, weightRows, karungRows, 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.karungs.list({ cycle_id: cycleId }).catch(() => []), + api.cycles?.retrieve?.(cycleId)?.catch(() => null) ?? Promise.resolve(null), + ]); if (freshCycle) { updateCycle(cycleId, freshCycle); } @@ -98,7 +94,7 @@ const Dashboard: React.FC = ({ setView }) => { setCountings(countingRows); setManualInputs(manualRows); setWeights(weightRows); - setKarung(latestKarung); + setKarungs(karungRows); // Best-effort IoT aggregates for end-cycle root cause (non-blocking on failure). try { @@ -180,10 +176,23 @@ const Dashboard: React.FC = ({ setView }) => { void 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(() => { // 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 anchorDate = latest?.date ?? null; @@ -285,7 +294,7 @@ const Dashboard: React.FC = ({ setView }) => { targetView: 'eefDashboard', }, ]; - }, [kpis, weights, selectedCycle]); + }, [visibleKpis, weights, selectedCycle, visibleThrough]); const countingAccuracyWarning = useMemo(() => { if (!selectedCycle) return null; @@ -299,8 +308,6 @@ const Dashboard: React.FC = ({ setView }) => { const insightContext = useMemo(() => { if (selectedKandangId == null) return null; - const visibleThrough = selectedCycle?.visible_through_date; - const visibleKpis = throughVisibleDate(kpis, visibleThrough); const latest = visibleKpis[0] ?? null; const anchorDate = latest?.date ?? null; const weightsThroughAnchor = anchorDate @@ -330,8 +337,10 @@ const Dashboard: React.FC = ({ setView }) => { ? null : Math.round((100 - latest.chicken_life_percentage) * 100) / 100, karung_dituang_hari_ini: - latest != null ? latest.feed : (karung?.feed_use_today ?? karung?.out_today ?? null), - pakan_kumulatif_karung: karung?.feed_use_total ?? latest?.feed_total ?? null, + latest != 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: latest != null ? Math.max(0, latest.chicken_life - (latest.harvest_total ?? 0)) : null, persen_hidup: latest?.chicken_life_percentage ?? null, @@ -343,12 +352,18 @@ const Dashboard: React.FC = ({ setView }) => { latest?.harvest_weight_total != null ? latest.harvest_weight_total / 1000 : null, uniformity_persen: weightOnAnchor?.uniformity ?? null, }); - }, [selectedKandangId, selectedKandang, selectedCycle, kpis, weights, karung]); + }, [ + selectedKandangId, + selectedKandang, + selectedCycle, + visibleKpis, + weights, + visibleThrough, + latestKarung, + ]); const insightMetrics = useMemo(() => { if (selectedKandangId == null) return null; - const visibleThrough = selectedCycle?.visible_through_date; - const visibleKpis = throughVisibleDate(kpis, visibleThrough); const latest = visibleKpis[0] ?? null; const anchorDate = latest?.date ?? null; const weightsThroughAnchor = anchorDate @@ -360,17 +375,17 @@ const Dashboard: React.FC = ({ setView }) => { weights: weightsThroughAnchor, currentDay: latest?.age ?? selectedCycle?.current_day ?? 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 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 feedInitialBalance = selectedCycle?.feed_initial_balance ?? 0; - const feedInTotal = (karung?.in_total ?? 0) + feedInitialBalance; - const feedOutTotal = karung?.out_total ?? 0; + const feedInTotal = (latestKarung?.in_total ?? 0) + feedInitialBalance; + const feedOutTotal = latestKarung?.out_total ?? 0; const feedSaldo = feedInTotal - feedTotal - feedOutTotal; const feedSaldoColor = feedSaldo > 0 ? 'text-green-600' : feedSaldo < 0 ? 'text-red-600' : 'text-gray-600'; diff --git a/components/FeedSackCountingPage.tsx b/components/FeedSackCountingPage.tsx index bf7de82..3c5df49 100644 --- a/components/FeedSackCountingPage.tsx +++ b/components/FeedSackCountingPage.tsx @@ -10,6 +10,7 @@ import { resolveCycleStatus, todayIso, } from '../utils/format.ts'; +import { throughVisibleDate } from '../utils/visibility.ts'; import { buildFeedSackInsightContext } from '../utils/insightContextBuilders.ts'; import FeedStats from './feedSack/FeedStats.tsx'; import FeedSaldoCard from './feedSack/FeedSaldoCard.tsx'; @@ -91,22 +92,30 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings }, [selectedCycle?.id, reload]); const sortedRows = useMemo(() => [...rows].sort((a, b) => a.date.localeCompare(b.date)), [rows]); - const latest = sortedRows[sortedRows.length - 1] ?? null; - const sortedManualRows = useMemo( () => [...manualRows].sort((a, b) => a.date.localeCompare(b.date)), [manualRows] ); const visibleThrough = selectedCycle?.visible_through_date; - // Display date follows the latest IoT snapshot; without any snapshot, fall back to today. - // Manual cards only use a same-date row — never fall back to an older day. + const visibleRows = useMemo( + () => 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 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(() => { 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); - }, [selectedCycle]); + }, [selectedCycle, visibleThrough]); const handleSync = useCallback(async () => { if (!selectedCycle) return; @@ -151,8 +160,8 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings const insightContext = useMemo(() => { if (!selectedKandang || !selectedCycle) return null; - const manualByDate = new Map(sortedManualRows.map((row) => [row.date, row])); - const dailyRows = sortedRows.map((row) => { + const manualByDate = new Map(visibleManualRows.map((row) => [row.date, row])); + const dailyRows = visibleRows.map((row) => { const hari = cycleDayForDate(selectedCycle.start_date, row.date); const manual = manualByDate.get(row.date); return { @@ -174,10 +183,10 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings total_karung_masuk_iot: latest?.in_total ?? null, total_karung_dituang_iot: latest?.feed_use_total ?? null, total_karung_keluar_iot: latest?.out_total ?? null, - total_karung_masuk_manual: latestManual?.feed_in_manual_total ?? null, - total_karung_dituang_manual: latestManual?.feed_use_manual_total ?? null, + total_karung_masuk_manual: lastRecordedManual?.feed_in_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 ; @@ -321,7 +330,7 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings {selectedKandang && insightContext ? ( void }> = ({ onSettings @@ -413,15 +423,15 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings {/* Tren harian */} {/* Rincian harian */} diff --git a/components/feedSack/FeedStats.tsx b/components/feedSack/FeedStats.tsx index 3f0c618..35d1401 100644 --- a/components/feedSack/FeedStats.tsx +++ b/components/feedSack/FeedStats.tsx @@ -30,6 +30,7 @@ const StatItem: React.FC<{ interface FeedStatsProps { latest: Karung | null; latestManual: ManualInput | null; + lastRecordedManual?: ManualInput | null; cycleStartDate: string; displayDate: string; } @@ -37,6 +38,7 @@ interface FeedStatsProps { const FeedStats: React.FC = ({ latest, latestManual, + lastRecordedManual, cycleStartDate, displayDate, }) => { @@ -61,6 +63,8 @@ const FeedStats: React.FC = ({ return `Total (${start} - ${end})`; }, [cycleStartDate, displayDate]); + const cumulativeManual = lastRecordedManual ?? latestManual; + const columns = useMemo( () => [ { @@ -71,7 +75,7 @@ const FeedStats: React.FC = ({ iotToday: latest?.in_today ?? 0, iotTotal: latest?.in_total ?? 0, manualToday: latestManual?.feed_in_manual ?? 0, - manualTotal: latestManual?.feed_in_manual_total ?? 0, + manualTotal: cumulativeManual?.feed_in_manual_total ?? 0, }, { key: 'dituang', @@ -81,7 +85,7 @@ const FeedStats: React.FC = ({ iotToday: latest?.feed_use_today ?? 0, iotTotal: latest?.feed_use_total ?? 0, manualToday: latestManual?.feed_use_manual ?? 0, - manualTotal: latestManual?.feed_use_manual_total ?? 0, + manualTotal: cumulativeManual?.feed_use_manual_total ?? 0, }, { key: 'keluar', @@ -91,10 +95,10 @@ const FeedStats: React.FC = ({ iotToday: latest?.out_today ?? 0, iotTotal: latest?.out_total ?? 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 ( diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api index 62b0695..519a1c5 100644 --- a/docker/Dockerfile.api +++ b/docker/Dockerfile.api @@ -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/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 \ && chmod 0644 /etc/cron.d/newdashboard \ && crontab /etc/cron.d/newdashboard