update sync data from site dashboard and testing closing cycles

This commit is contained in:
Alberto-Audrix committed 2026-09-15 16:52:41 +07:00
1 parent 36639d8c38
commit f49e72592c
42 files changed
+1966 -688

No files matched your search

+33 -31
View File
@@ -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;
-18
View File
@@ -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 && (
-18
View File
@@ -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 && (
+1 -43
View File
@@ -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
View File
@@ -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
View File
@@ -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>
}
/>
+2 -1
View File
@@ -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 />
+1 -24
View File
@@ -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();
+5 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
)}
+21 -102
View File
@@ -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>