update sync data from site dashboard and testing closing cycles
This commit is contained in:
1 parent
36639d8c38
commit
f49e72592c
42 files changed
+1966
-688
No files matched your search
+33
-31
@@ -6,7 +6,6 @@ import type {
|
||||
ChickenWeight,
|
||||
Karung,
|
||||
KPI,
|
||||
LatestAvg,
|
||||
ManualInput,
|
||||
} from '../types/api.ts';
|
||||
import type { View } from '../types/common.ts';
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
formatNumber,
|
||||
resolveCycleStatus,
|
||||
} from '../utils/format.ts';
|
||||
import { isOnOrBeforeVisibleDate, throughVisibleDate } from '../utils/visibility.ts';
|
||||
import { throughVisibleDate } from '../utils/visibility.ts';
|
||||
import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts';
|
||||
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
|
||||
import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx';
|
||||
@@ -44,7 +43,6 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
const [manualInputs, setManualInputs] = useState<ManualInput[]>([]);
|
||||
const [weights, setWeights] = useState<ChickenWeight[]>([]);
|
||||
const [karung, setKarung] = useState<Karung | null>(null);
|
||||
const [latestAvg, setLatestAvg] = useState<LatestAvg | null>(null);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [reportType, setReportType] = useState<DashboardReportType>('daily');
|
||||
@@ -56,7 +54,6 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
setManualInputs([]);
|
||||
setWeights([]);
|
||||
setKarung(null);
|
||||
setLatestAvg(null);
|
||||
setInsights([]);
|
||||
return;
|
||||
}
|
||||
@@ -79,19 +76,12 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 404)) throw err;
|
||||
}
|
||||
let avg: LatestAvg | null = null;
|
||||
try {
|
||||
avg = await api.weights.latestAvg({ cycle_id: cycleId });
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 404)) throw err;
|
||||
}
|
||||
if (cancelled) return;
|
||||
setKpis(kpiRows);
|
||||
setCountings(countingRows);
|
||||
setManualInputs(manualRows);
|
||||
setWeights(weightRows);
|
||||
setKarung(latestKarung);
|
||||
setLatestAvg(avg);
|
||||
setInsights(insightRows);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(errorMessage(err));
|
||||
@@ -105,40 +95,52 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
}, [selectedCycle]);
|
||||
|
||||
const cards: KpiCardModel[] = useMemo(() => {
|
||||
// Countings/weights APIs are real-time; KPI cards still honor publish cutoff.
|
||||
// All cards share the latest complete KPI day (not calendar current_day / live IoT).
|
||||
// Synced KPI rows only exist for days that had manual + sources on the site.
|
||||
const visibleThrough = selectedCycle?.visible_through_date;
|
||||
const visibleKpis = throughVisibleDate(kpis, visibleThrough);
|
||||
const visibleCountings = throughVisibleDate(countings, visibleThrough);
|
||||
const visibleWeights = throughVisibleDate(weights, visibleThrough);
|
||||
const visibleLatestAvg =
|
||||
latestAvg && isOnOrBeforeVisibleDate(latestAvg.date, visibleThrough) ? latestAvg : null;
|
||||
const visibleKarung =
|
||||
karung && isOnOrBeforeVisibleDate(karung.date, visibleThrough) ? karung : null;
|
||||
const latest = visibleKpis[0] ?? null;
|
||||
const anchorDate = latest?.date ?? null;
|
||||
|
||||
const kpisThroughAnchor = anchorDate
|
||||
? visibleKpis.filter((row) => row.date <= anchorDate)
|
||||
: [];
|
||||
const weightsThroughAnchor = anchorDate
|
||||
? throughVisibleDate(weights, visibleThrough).filter((row) => row.date <= anchorDate)
|
||||
: [];
|
||||
const weightOnAnchor = anchorDate
|
||||
? weightsThroughAnchor.find((row) => row.date === anchorDate) ?? weightsThroughAnchor[0]
|
||||
: null;
|
||||
|
||||
const latest = visibleKpis[0];
|
||||
const startDate = selectedCycle?.start_date;
|
||||
const trendLabel = (isoDate: string): string => {
|
||||
const day = startDate ? cycleDayForDate(startDate, isoDate) : 0;
|
||||
return `Hari ${day} · ${formatDateId(isoDate)}`;
|
||||
};
|
||||
const fcrTrend: KpiTrendPoint[] = chronological(visibleKpis).map((row) => ({
|
||||
const fcrTrend: KpiTrendPoint[] = chronological(kpisThroughAnchor).map((row) => ({
|
||||
value: row.fcr,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const eefTrend: KpiTrendPoint[] = chronological(visibleKpis).map((row) => ({
|
||||
const eefTrend: KpiTrendPoint[] = chronological(kpisThroughAnchor).map((row) => ({
|
||||
value: row.eef,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const mortalityTrend: KpiTrendPoint[] = chronological(visibleCountings).map((row) => ({
|
||||
value: row.mortality_count,
|
||||
const mortalityTrend: KpiTrendPoint[] = chronological(kpisThroughAnchor).map((row) => ({
|
||||
value: row.mortality,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const mortalityToday = latest?.mortality ?? visibleCountings[0]?.mortality_count ?? null;
|
||||
const weightTrend: KpiTrendPoint[] = chronological(visibleWeights).map((row) => ({
|
||||
const weightTrend: KpiTrendPoint[] = chronological(weightsThroughAnchor).map((row) => ({
|
||||
value: row.average_weight,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const age = selectedCycle?.current_day ?? latest?.age ?? null;
|
||||
const age = latest?.age ?? null;
|
||||
const mortalityToday = latest?.mortality ?? null;
|
||||
const feedToday = latest != null ? latest.feed : null;
|
||||
const weightToday =
|
||||
latest && latest.iot_weight > 0
|
||||
? latest.iot_weight
|
||||
: (weightOnAnchor?.average_weight ?? null);
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'umur',
|
||||
@@ -161,20 +163,20 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
{
|
||||
key: 'karung',
|
||||
title: 'Karung Dituang',
|
||||
value: visibleKarung?.feed_use_today ?? visibleKarung?.out_today ?? null,
|
||||
value: feedToday,
|
||||
unit: 'karung',
|
||||
icon: '🛢️',
|
||||
status: visibleKarung ? 'ok' : 'na',
|
||||
status: feedToday == null ? 'na' : 'ok',
|
||||
targetView: 'feedSackCounting',
|
||||
},
|
||||
{
|
||||
key: 'weight',
|
||||
title: 'Berat AVG',
|
||||
value: visibleLatestAvg?.average_weight ?? visibleWeights[0]?.average_weight ?? null,
|
||||
value: weightToday,
|
||||
unit: 'gram',
|
||||
icon: '⚖️',
|
||||
trend: weightTrend,
|
||||
status: visibleLatestAvg || visibleWeights[0] ? 'ok' : 'na',
|
||||
status: weightToday == null ? 'na' : 'ok',
|
||||
targetView: 'weightMonitoring',
|
||||
},
|
||||
{
|
||||
@@ -198,7 +200,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
targetView: 'eefDashboard',
|
||||
},
|
||||
];
|
||||
}, [kpis, countings, weights, karung, latestAvg, selectedCycle]);
|
||||
}, [kpis, weights, selectedCycle]);
|
||||
|
||||
const countingAccuracyWarning = useMemo(() => {
|
||||
if (!selectedCycle) return null;
|
||||
|
||||
@@ -168,7 +168,6 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
const [rows, setRows] = useState<KPI[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
@@ -209,15 +208,6 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
};
|
||||
}, [selectedCycle, loadData]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await loadData();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const eefRows = useMemo<EefRow[]>(
|
||||
() =>
|
||||
[...rows]
|
||||
@@ -288,14 +278,6 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
subtitle="Dashboard European Efficiency Factor berdasarkan data performa produksi IoT."
|
||||
icon={<i className="fa-solid fa-gauge-high" />}
|
||||
/>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || loading}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-400"
|
||||
>
|
||||
<i className={`fa-solid fa-rotate-right ${refreshing || loading ? 'fa-spin' : ''}`} />
|
||||
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -160,7 +160,6 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
const [weightRows, setWeightRows] = useState<ChickenWeight[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
@@ -204,15 +203,6 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
};
|
||||
}, [selectedCycle, loadData]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await loadData();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const weightByAge = useMemo(() => {
|
||||
const map = new Map<number, number>();
|
||||
for (const weight of weightRows) {
|
||||
@@ -293,14 +283,6 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
subtitle="Dashboard Feed Conversion Ratio berdasarkan data pakan, bobot IoT, ayam hidup, dan panen."
|
||||
icon={<i className="fa-solid fa-chart-line" />}
|
||||
/>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || loading}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-400"
|
||||
>
|
||||
<i className={`fa-solid fa-rotate-right ${refreshing || loading ? 'fa-spin' : ''}`} />
|
||||
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -17,9 +17,7 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [showCyclePicker, setShowCyclePicker] = useState(false);
|
||||
|
||||
const reload = useCallback(async (cycleId: number) => {
|
||||
@@ -69,22 +67,6 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
return Math.min(rawDay, Math.max(0, selectedCycle.total_days - 1));
|
||||
}, [selectedCycle, displayDate]);
|
||||
|
||||
const handleSync = useCallback(async () => {
|
||||
if (!selectedCycle) return;
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await api.karungs.request({ cycle_id: selectedCycle.id, section: 'today' });
|
||||
setNotice('Sinkron karung-web-admin berhasil.');
|
||||
await reload(selectedCycle.id);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [selectedCycle, reload]);
|
||||
|
||||
const sortedCycles = useMemo(() => {
|
||||
return [...cycles].sort(
|
||||
(a, b) => new Date(b.start_date).getTime() - new Date(a.start_date).getTime()
|
||||
@@ -131,25 +113,6 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing || cycleStatus !== 'active'}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-60 hover:bg-red-700 transition-colors"
|
||||
>
|
||||
{syncing ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Sync...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="fa-solid fa-arrows-rotate mr-2"></i>
|
||||
Sync hari ini
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cycle Selector */}
|
||||
@@ -229,11 +192,6 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
<Banner kind="error">{error}</Banner>
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="mb-4">
|
||||
<Banner kind="success">{notice}</Banner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
@@ -311,7 +269,7 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-xs text-gray-400">
|
||||
Sumber data: karung-web-admin · {selectedSite ? selectedSite.site_name : ''}
|
||||
Sumber data: site dashboard (mirror) · {selectedSite ? selectedSite.site_name : ''}
|
||||
{selectedKandang ? ` · ${selectedKandang.kandang_name}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+37
-2
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext.tsx';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import { api, errorMessage } from '../services/apiClient.ts';
|
||||
import type { UserStatus } from '../types/api.ts';
|
||||
import { formatDateId, resolveCycleStatus } from '../utils/format.ts';
|
||||
|
||||
@@ -22,6 +23,10 @@ type HeaderProps = {
|
||||
onToggleTimeline: () => void;
|
||||
};
|
||||
|
||||
function canMirrorSync(status?: UserStatus | string | null): boolean {
|
||||
return status === 'gm' || status === 'gm_admin';
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({
|
||||
onToggleSidebar,
|
||||
isTimelineVisible,
|
||||
@@ -38,12 +43,15 @@ const Header: React.FC<HeaderProps> = ({
|
||||
setSelectedSiteId,
|
||||
setSelectedKandangId,
|
||||
setSelectedCycleId,
|
||||
reload: reloadFarm,
|
||||
} = useFarm();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [gmFilter, setGmFilter] = useState<'all' | number>('all');
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isBuh = user?.status === 'buh' || user?.status === 'bu_admin';
|
||||
const showSync = canMirrorSync(user?.status);
|
||||
|
||||
const gmOptions = useMemo(() => {
|
||||
const byId = new Map<number, string>();
|
||||
@@ -81,6 +89,19 @@ const Header: React.FC<HeaderProps> = ({
|
||||
return () => document.removeEventListener('mousedown', onClick);
|
||||
}, []);
|
||||
|
||||
const handleMirrorSync = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await api.activeSites.syncAll();
|
||||
await reloadFarm();
|
||||
} catch (err) {
|
||||
// Keep sync failures visible in the browser console; UI stays quiet.
|
||||
console.error(errorMessage(err));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
@@ -164,7 +185,21 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center ml-auto">
|
||||
<div className="flex items-center ml-auto gap-2">
|
||||
{showSync && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleMirrorSync()}
|
||||
disabled={syncing}
|
||||
title="Sinkronkan data dari site dashboard"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60"
|
||||
>
|
||||
<i
|
||||
className={`fa-solid ${syncing ? 'fa-spinner fa-spin' : 'fa-arrows-rotate'}`}
|
||||
/>
|
||||
<span className="hidden sm:inline">{syncing ? 'Sync...' : 'Sync'}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onToggleTimeline}
|
||||
title="Toggle Linimasa"
|
||||
@@ -175,7 +210,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className="relative ml-4" ref={dropdownRef}>
|
||||
<div className="relative ml-2" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center space-x-2 focus:outline-none"
|
||||
|
||||
+18
-37
@@ -59,9 +59,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
const [dayRows, setDayRows] = useState<PanelIoT[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const [selectedChartKey, setSelectedChartKey] = useState<'humidity' | 'wind' | 'water' | null>(
|
||||
null
|
||||
);
|
||||
@@ -102,7 +100,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedKandang, refreshToken]);
|
||||
}, [selectedKandang]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCycle || selectedFlockId == null) {
|
||||
@@ -139,7 +137,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedCycle, selectedFlockId, refreshToken]);
|
||||
}, [selectedCycle, selectedFlockId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCycle || selectedFlockId == null || !selectedDate) {
|
||||
@@ -171,7 +169,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedCycle, selectedFlockId, selectedDate, refreshToken]);
|
||||
}, [selectedCycle, selectedFlockId, selectedDate]);
|
||||
|
||||
const summary = useMemo<DaySummary | null>(() => {
|
||||
if (dayRows.length === 0) return null;
|
||||
@@ -240,12 +238,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
return (matched.length > 0 ? matched : insights).slice(0, 4);
|
||||
}, [insights]);
|
||||
|
||||
const refresh = () => {
|
||||
setRefreshing(true);
|
||||
setRefreshToken((token) => token + 1);
|
||||
window.setTimeout(() => setRefreshing(false), 500);
|
||||
};
|
||||
|
||||
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
||||
|
||||
return (
|
||||
@@ -255,32 +247,21 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
subtitle={`${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
|
||||
icon={<i className="fa-solid fa-table-columns" />}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedFlockId ?? ''}
|
||||
onChange={(event) =>
|
||||
setSelectedFlockId(event.target.value ? Number(event.target.value) : null)
|
||||
}
|
||||
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700"
|
||||
aria-label="Pilih flock"
|
||||
>
|
||||
{flocks.length === 0 && <option value="">Tidak ada flock</option>}
|
||||
{flocks.map((flock) => (
|
||||
<option key={flock.id} value={flock.id}>
|
||||
{flock.flock_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={refreshing}
|
||||
className="rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100 disabled:opacity-60"
|
||||
>
|
||||
<i className={`fa-solid fa-rotate-right mr-1 ${refreshing ? 'fa-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={selectedFlockId ?? ''}
|
||||
onChange={(event) =>
|
||||
setSelectedFlockId(event.target.value ? Number(event.target.value) : null)
|
||||
}
|
||||
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700"
|
||||
aria-label="Pilih flock"
|
||||
>
|
||||
{flocks.length === 0 && <option value="">Tidak ada flock</option>}
|
||||
{flocks.map((flock) => (
|
||||
<option key={flock.id} value={flock.id}>
|
||||
{flock.flock_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -433,7 +433,8 @@ const GmViewList: React.FC = () => {
|
||||
<section className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 className="font-semibold text-gray-800 mb-1">Daftar tampilan</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Site yang terdaftar untuk akun GM / GM Admin Anda.
|
||||
Site yang terdaftar untuk akun GM / GM Admin Anda. Sinkronisasi data memakai tombol Sync
|
||||
di header.
|
||||
</p>
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
|
||||
@@ -16,7 +16,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
const [latest, setLatest] = useState<LatestAvg | null>(null);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedDate, setSelectedDate] = useState('');
|
||||
|
||||
@@ -51,7 +50,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setRefreshing(false);
|
||||
try {
|
||||
await reload(selectedCycle.id);
|
||||
} finally {
|
||||
@@ -78,18 +76,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
}
|
||||
}, [rows, chartRows, selectedDate]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!selectedCycle) return;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await reload(selectedCycle.id);
|
||||
} catch {
|
||||
// error state is set inside reload
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const docWeight = selectedCycle?.chick_in_weight ?? 35;
|
||||
|
||||
const selectedRow = useMemo(
|
||||
@@ -149,15 +135,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || loading}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
title="Refresh data manual"
|
||||
>
|
||||
<i className={`fa-solid fa-refresh ${refreshing || loading ? 'fa-spin' : ''}`}></i>
|
||||
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -297,7 +274,7 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-900 mb-2">Informasi Pembaruan Data</h3>
|
||||
<ul className="text-sm text-blue-800 space-y-1">
|
||||
<li>• Klik tombol "Refresh" untuk memperbarui data secara manual</li>
|
||||
<li>• Data berat diperbarui lewat Sync di header (dari site dashboard)</li>
|
||||
<li>• Grafik menampilkan data dari awal siklus hingga hari ini</li>
|
||||
<li>• Target berat menggunakan standar performa Ross 308</li>
|
||||
</ul>
|
||||
|
||||
@@ -229,7 +229,7 @@ describe('FeedSackCountingPage', () => {
|
||||
screen.getByText('Monitoring karung masuk dan karung dituang per hari')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/10 Agu 2026 - 13 Sep 2026/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Sync hari ini/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Sync/i })).not.toBeInTheDocument();
|
||||
|
||||
// Ringkasan stats
|
||||
expect(screen.getAllByText('Karung Masuk').length).toBeGreaterThan(0);
|
||||
@@ -240,6 +240,13 @@ describe('FeedSackCountingPage', () => {
|
||||
// Saldo card
|
||||
expect(screen.getByRole('heading', { name: 'Saldo Karung IOT' })).toBeInTheDocument();
|
||||
|
||||
// Saldo awal (read-only from site mirror)
|
||||
expect(screen.getByRole('heading', { name: 'Saldo Awal' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Belum ada saldo awal tersimpan untuk siklus ini.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Sumber data: site dashboard \(mirror\)/i)).toBeInTheDocument();
|
||||
|
||||
// Trend chart
|
||||
expect(screen.getByRole('heading', { name: 'Tren Harian Karung IOT' })).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ describe('SettingsPage city site registry', () => {
|
||||
id: 1,
|
||||
code: 'sukawarna',
|
||||
name: 'Sukawarna',
|
||||
source_site_id: 'sukawarna-0001',
|
||||
api_base_url: 'https://sukawarna.example/api/v1',
|
||||
has_api_key: true,
|
||||
managed_by: 5,
|
||||
@@ -117,6 +118,7 @@ describe('SettingsPage city site registry', () => {
|
||||
id: 2,
|
||||
code: 'cikupa',
|
||||
name: 'Cikupa',
|
||||
source_site_id: 'cikupa-0001',
|
||||
api_base_url: 'https://cikupa.example/api/v1',
|
||||
has_api_key: false,
|
||||
managed_by: 5,
|
||||
@@ -165,7 +167,9 @@ describe('SettingsPage city site registry', () => {
|
||||
expect(createMock).toHaveBeenCalledWith({
|
||||
code: 'cikupa',
|
||||
});
|
||||
}); it('lets GM request a BUH', async () => {
|
||||
});
|
||||
|
||||
it('lets GM request a BUH', async () => {
|
||||
requestMock.mockResolvedValue({
|
||||
...authState.user,
|
||||
managed_by_pending: 2,
|
||||
|
||||
@@ -51,9 +51,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [searchDay, setSearchDay] = useState<string>('');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
@@ -91,22 +89,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
void loadData(selectedCycle.id);
|
||||
}, [selectedCycle, loadData]);
|
||||
|
||||
const handleSync = useCallback(async () => {
|
||||
if (!selectedCycle) return;
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await api.countings.sync({ cycle_id: selectedCycle.id });
|
||||
setNotice('Sinkron chicken counting edge berhasil.');
|
||||
await loadData(selectedCycle.id);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [selectedCycle, loadData]);
|
||||
|
||||
// Build daily rows through calendar today (not publish-gated current_day).
|
||||
const rows = useMemo<Row[]>(() => {
|
||||
if (!selectedCycle) return [];
|
||||
@@ -281,26 +263,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
title="Hitung Ayam"
|
||||
icon={<i className="fa-solid fa-calculator" />}
|
||||
subtitle={`Lihat data deplesi harian untuk siklus produksi · ${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSync}
|
||||
disabled={syncing || isClosedCycle}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-60 hover:bg-red-700 transition-colors"
|
||||
>
|
||||
{syncing ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Sync...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="fa-solid fa-arrows-rotate mr-2"></i>
|
||||
Sync
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{countingAccuracyWarning && (
|
||||
@@ -351,11 +313,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
<Banner kind="error">{error}</Banner>
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="mb-4">
|
||||
<Banner kind="success">{notice}</Banner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import DataManagementPage from '../DataManagementPage.tsx';
|
||||
import { api } from '../../../services/apiClient.ts';
|
||||
@@ -12,7 +12,6 @@ vi.mock('../../../services/apiClient.ts', () => ({
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
sync: vi.fn().mockResolvedValue({ upserted: 2, dates: ['2026-09-02', '2026-09-03'] }),
|
||||
},
|
||||
manualInputs: {
|
||||
list: vi.fn().mockResolvedValue([
|
||||
@@ -78,10 +77,6 @@ describe('DataManagementPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.kpis.list).mockResolvedValue([]);
|
||||
vi.mocked(api.countings.list).mockResolvedValue([]);
|
||||
vi.mocked(api.countings.sync).mockResolvedValue({
|
||||
upserted: 2,
|
||||
dates: ['2026-09-02', '2026-09-03'],
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the Hitung Ayam dashboard view with stats and daily rows', async () => {
|
||||
@@ -114,17 +109,6 @@ describe('DataManagementPage', () => {
|
||||
expect(screen.getAllByText('0.0%').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calls chicken counting sync when Sync is clicked', async () => {
|
||||
render(<DataManagementPage onSettings={() => undefined} />);
|
||||
|
||||
await screen.findByRole('heading', { name: 'Hitung Ayam' });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sync/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.countings.sync).toHaveBeenCalledWith({ cycle_id: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
it('shows under-count IoT accuracy warning for the latest IoT day', async () => {
|
||||
vi.mocked(api.kpis.list).mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -93,7 +93,7 @@ const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
|
||||
<i className="fa-solid fa-table text-5xl text-gray-300 mb-3"></i>
|
||||
<p className="font-medium text-gray-600">Tidak ada data</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Belum ada data karung IOT untuk siklus ini. Sinkronkan lewat tombol Sync hari ini.
|
||||
Belum ada data karung IOT untuk siklus ini. Gunakan Sync di header untuk menarik data dari site.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -109,7 +109,7 @@ const FeedSaldoCard: React.FC<FeedSaldoCardProps> = ({
|
||||
<i className="fa-solid fa-triangle-exclamation text-yellow-600 mt-0.5"></i>
|
||||
<p className="text-xs text-yellow-800">
|
||||
<strong>Peringatan:</strong> Saldo negatif menunjukkan karung dituang lebih banyak dari
|
||||
karung masuk. Periksa kembali data sinkronisasi karung-web-admin.
|
||||
karung masuk. Pastikan site sudah disinkronkan dari header.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { api, errorMessage } from '../../services/apiClient.ts';
|
||||
import type { Cycle, InitialBalanceResult } from '../../types/api.ts';
|
||||
import { formatNumber } from '../../utils/format.ts';
|
||||
import React from 'react';
|
||||
import type { Cycle } from '../../types/api.ts';
|
||||
import InitialBalanceComparison from './InitialBalanceComparison.tsx';
|
||||
|
||||
interface InitialBalanceCardProps {
|
||||
@@ -9,53 +7,9 @@ interface InitialBalanceCardProps {
|
||||
}
|
||||
|
||||
const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle }) => {
|
||||
const savedBalanceDate = cycle.feed_initial_balance_date ?? cycle.start_date;
|
||||
const [balanceDate, setBalanceDate] = useState(savedBalanceDate);
|
||||
const [manualEntry, setManualEntry] = useState<string>(
|
||||
cycle.feed_initial_balance > 0 ? String(cycle.feed_initial_balance) : ''
|
||||
);
|
||||
const [comparing, setComparing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [comparison, setComparison] = useState<InitialBalanceResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBalanceDate(cycle.feed_initial_balance_date ?? cycle.start_date);
|
||||
setManualEntry(cycle.feed_initial_balance > 0 ? String(cycle.feed_initial_balance) : '');
|
||||
setComparison(null);
|
||||
setError(null);
|
||||
}, [cycle.id, cycle.feed_initial_balance, cycle.feed_initial_balance_date, cycle.start_date]);
|
||||
|
||||
const manualValue = manualEntry === '' ? null : Number(manualEntry);
|
||||
|
||||
const loadComparison = useCallback(async () => {
|
||||
if (balanceDate === '' || manualValue == null || Number.isNaN(manualValue) || manualValue < 0) {
|
||||
setComparison(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setComparing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const compared = await api.cycles.initialBalanceCompare(cycle.id, {
|
||||
date: balanceDate,
|
||||
manual: manualValue,
|
||||
});
|
||||
setComparison(compared);
|
||||
} catch (err) {
|
||||
setComparison(null);
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setComparing(false);
|
||||
}
|
||||
}, [balanceDate, cycle.id, manualValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (manualValue == null || manualValue < 0) {
|
||||
setComparison(null);
|
||||
return;
|
||||
}
|
||||
void loadComparison();
|
||||
}, [loadComparison, manualValue]);
|
||||
const hasBalance =
|
||||
cycle.feed_initial_balance > 0 || Boolean(cycle.feed_initial_balance_date);
|
||||
const balanceDate = cycle.feed_initial_balance_date ?? cycle.start_date;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-6 rounded-xl shadow-sm border-2 border-blue-200">
|
||||
@@ -66,64 +20,29 @@ const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle }) => {
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">Saldo Awal</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Masukkan saldo awal karung pakan pada tanggal tertentu, lalu bandingkan dengan deteksi
|
||||
IoT.
|
||||
Saldo awal karung pakan dari site dashboard (mirror sync).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-6 border border-blue-200">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="initial-balance-date"
|
||||
className="text-sm font-semibold text-gray-700 block mb-1"
|
||||
>
|
||||
Tanggal
|
||||
</label>
|
||||
<input
|
||||
id="initial-balance-date"
|
||||
type="date"
|
||||
value={balanceDate}
|
||||
onChange={(event) => setBalanceDate(event.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
{!hasBalance ? (
|
||||
<p className="text-sm text-gray-600">Belum ada saldo awal tersimpan untuk siklus ini.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
Tanggal:{' '}
|
||||
<strong className="text-gray-800">{balanceDate}</strong>
|
||||
</p>
|
||||
<InitialBalanceComparison
|
||||
date={balanceDate}
|
||||
manual={cycle.feed_initial_balance}
|
||||
iot={cycle.feed_initial_balance_iot ?? null}
|
||||
accuracy={cycle.feed_initial_balance_accuracy ?? null}
|
||||
syncError={cycle.feed_initial_balance_sync_error ?? null}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="initial-balance-manual"
|
||||
className="text-sm font-semibold text-gray-700 block mb-1"
|
||||
>
|
||||
Feed In Manual (karung)
|
||||
</label>
|
||||
<input
|
||||
id="initial-balance-manual"
|
||||
type="number"
|
||||
min={0}
|
||||
value={manualEntry}
|
||||
onChange={(event) => setManualEntry(event.target.value)}
|
||||
placeholder="Contoh: 120"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 text-sm font-semibold text-red-700 bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
{error}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{cycle.feed_initial_balance > 0 && comparison == null && (
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Saldo awal tersimpan:{' '}
|
||||
<strong className="text-blue-700">
|
||||
{formatNumber(cycle.feed_initial_balance)} karung
|
||||
</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{comparison && <InitialBalanceComparison result={comparison} loading={comparing} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import React from 'react';
|
||||
import type { InitialBalanceResult } from '../../types/api.ts';
|
||||
import { formatNumber } from '../../utils/format.ts';
|
||||
|
||||
interface InitialBalanceComparisonProps {
|
||||
result: InitialBalanceResult;
|
||||
date: string;
|
||||
manual: number;
|
||||
iot: number | null;
|
||||
accuracy: number | null;
|
||||
syncError?: string | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const InitialBalanceComparison: React.FC<InitialBalanceComparisonProps> = ({
|
||||
result,
|
||||
date,
|
||||
manual,
|
||||
iot,
|
||||
accuracy,
|
||||
syncError = null,
|
||||
loading = false,
|
||||
}) => {
|
||||
const accuracy = result.accuracy;
|
||||
const accuracyLabel = accuracy == null ? 'N/A' : `${formatNumber(accuracy, 1)}%`;
|
||||
const accuracyColor =
|
||||
accuracy == null
|
||||
@@ -25,18 +31,18 @@ const InitialBalanceComparison: React.FC<InitialBalanceComparisonProps> = ({
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50/50 p-4">
|
||||
<p className="text-sm font-semibold text-gray-800 mb-3">
|
||||
Perbandingan {result.date}
|
||||
{loading ? ' · memuat IoT...' : ''}
|
||||
Perbandingan {date}
|
||||
{loading ? ' · memuat...' : ''}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-center">
|
||||
<div className="rounded-lg bg-white border border-gray-200 p-3">
|
||||
<p className="text-xs font-semibold text-blue-700">Manual</p>
|
||||
<p className="text-2xl font-bold text-gray-800">{formatNumber(result.manual)} karung</p>
|
||||
<p className="text-2xl font-bold text-gray-800">{formatNumber(manual)} karung</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white border border-gray-200 p-3">
|
||||
<p className="text-xs font-semibold text-purple-700">IOT</p>
|
||||
<p className="text-2xl font-bold text-gray-800">
|
||||
{result.iot == null ? 'N/A' : `${formatNumber(result.iot)} karung`}
|
||||
{iot == null ? 'N/A' : `${formatNumber(iot)} karung`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white border border-gray-200 p-3">
|
||||
@@ -44,9 +50,9 @@ const InitialBalanceComparison: React.FC<InitialBalanceComparisonProps> = ({
|
||||
<p className={`text-2xl font-bold ${accuracyColor}`}>{accuracyLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.sync_error && (
|
||||
{syncError && (
|
||||
<p className="mt-3 text-xs text-yellow-700 bg-yellow-50 border border-yellow-200 rounded-lg p-2">
|
||||
Gagal menarik data IoT dari karung-web-admin: {result.sync_error}
|
||||
Sinkron IoT di site: {syncError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user