diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index bcd15ce..8fd86b3 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -7,6 +7,7 @@ import type { Karung, KPI, LatestAvg, + ManualInput, } from '../types/api.ts'; import type { View } from '../types/common.ts'; import { useFarm } from '../context/FarmContext.tsx'; @@ -20,9 +21,11 @@ import { resolveCycleStatus, } from '../utils/format.ts'; import { isOnOrBeforeVisibleDate, throughVisibleDate } from '../utils/visibility.ts'; +import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts'; import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx'; import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx'; import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx'; +import CountingAccuracyWarningBanner from './counting/CountingAccuracyWarningBanner.tsx'; import type { DashboardReportType } from '../types/dashboardReport.ts'; type Props = { setView: (view: View) => void }; @@ -37,6 +40,7 @@ const Dashboard: React.FC = ({ setView }) => { const [error, setError] = useState(null); const [kpis, setKpis] = useState([]); const [countings, setCountings] = useState([]); + const [manualInputs, setManualInputs] = useState([]); const [weights, setWeights] = useState([]); const [karung, setKarung] = useState(null); const [latestAvg, setLatestAvg] = useState(null); @@ -48,6 +52,7 @@ const Dashboard: React.FC = ({ setView }) => { if (!selectedCycle) { setKpis([]); setCountings([]); + setManualInputs([]); setWeights([]); setKarung(null); setLatestAvg(null); @@ -60,9 +65,10 @@ const Dashboard: React.FC = ({ setView }) => { setLoading(true); setError(null); try { - const [kpiRows, countingRows, weightRows, insightRows] = await Promise.all([ + const [kpiRows, countingRows, manualRows, weightRows, insightRows] = 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.insights.list({ cycle_id: cycleId }), ]); @@ -81,6 +87,7 @@ const Dashboard: React.FC = ({ setView }) => { if (cancelled) return; setKpis(kpiRows); setCountings(countingRows); + setManualInputs(manualRows); setWeights(weightRows); setKarung(latestKarung); setLatestAvg(avg); @@ -192,6 +199,16 @@ const Dashboard: React.FC = ({ setView }) => { ]; }, [kpis, countings, weights, karung, latestAvg, selectedCycle]); + const countingAccuracyWarning = useMemo(() => { + if (!selectedCycle) return null; + return getCountingAccuracyWarning({ + cycle: selectedCycle, + countings, + kpis, + manualInputs, + }); + }, [selectedCycle, countings, kpis, manualInputs]); + if (!selectedCycle) return setView('kandangSettings')} />; const feedTotal = karung?.feed_use_total ?? kpis[0]?.feed_total ?? 0; @@ -230,6 +247,9 @@ const Dashboard: React.FC = ({ setView }) => { {error} )} + {countingAccuracyWarning && ( + + )} {loading ? ( ) : ( diff --git a/components/counting/CountingAccuracyWarningBanner.tsx b/components/counting/CountingAccuracyWarningBanner.tsx new file mode 100644 index 0000000..5255136 --- /dev/null +++ b/components/counting/CountingAccuracyWarningBanner.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import type { CountingAccuracyWarning } from '../../utils/countingAccuracyWarning.ts'; +import { formatNumber } from '../../utils/format.ts'; + +type Props = { + warning: CountingAccuracyWarning; +}; + +const CountingAccuracyWarningBanner: React.FC = ({ warning }) => { + const isOver = warning.direction === 'over'; + const title = isOver + ? 'Hitung ayam IoT lebih tinggi dari stock manual' + : 'Akurasi hitung ayam IoT rendah (IoT lebih rendah dari stock)'; + const body = isOver + ? 'Manager perlu cek: kemungkinan fraud / ayam baru yang belum tercatat, atau AI overcount; pastikan juga video kamera lengkap dan tidak bermasalah.' + : 'Manager perlu cek: video kamera lengkap atau tidak, kemungkinan masalah AI, atau ada selisih stok.'; + + return ( +
+
+ +
+

{title}

+

+ Hari {warning.day} · IoT {formatNumber(warning.iotCount, 0)} vs stock{' '} + {formatNumber(warning.stockAkhir, 0)} (akurasi {formatNumber(warning.accuracy, 1)}%) +

+

{body}

+
+
+
+ ); +}; + +export default CountingAccuracyWarningBanner; diff --git a/components/counting/DataManagementPage.tsx b/components/counting/DataManagementPage.tsx index d3c4259..789dbc4 100644 --- a/components/counting/DataManagementPage.tsx +++ b/components/counting/DataManagementPage.tsx @@ -13,6 +13,8 @@ import { todayIso, } from '../../utils/format.ts'; import PopulationTrendChart from './PopulationTrendChart.tsx'; +import CountingAccuracyWarningBanner from './CountingAccuracyWarningBanner.tsx'; +import { getCountingAccuracyWarning } from '../../utils/countingAccuracyWarning.ts'; type Row = { day: number; @@ -213,6 +215,16 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } [rows] ); + const countingAccuracyWarning = useMemo(() => { + if (!selectedCycle) return null; + return getCountingAccuracyWarning({ + cycle: selectedCycle, + countings, + kpis, + manualInputs: manualRows, + }); + }, [selectedCycle, countings, kpis, manualRows]); + const filteredRecords = useMemo(() => { let filtered = rows; if (searchDay) { @@ -289,6 +301,10 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } } /> + {countingAccuracyWarning && ( + + )} + {/* AI Insight */}

diff --git a/components/counting/__tests__/CountingAccuracyWarningBanner.test.tsx b/components/counting/__tests__/CountingAccuracyWarningBanner.test.tsx new file mode 100644 index 0000000..a7c4697 --- /dev/null +++ b/components/counting/__tests__/CountingAccuracyWarningBanner.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import CountingAccuracyWarningBanner from '../CountingAccuracyWarningBanner.tsx'; +import type { CountingAccuracyWarning } from '../../../utils/countingAccuracyWarning.ts'; + +const base: CountingAccuracyWarning = { + day: 2, + date: '2026-08-12', + iotCount: 900, + stockAkhir: 1000, + accuracy: 90, + ratioPercent: 90, + direction: 'under', +}; + +describe('CountingAccuracyWarningBanner', () => { + it('renders under-count manager checklist', () => { + render(); + expect(screen.getByText(/IoT lebih rendah dari stock/i)).toBeInTheDocument(); + expect(screen.getByText(/video kamera lengkap/i)).toBeInTheDocument(); + expect(screen.getByText(/Hari 2/i)).toBeInTheDocument(); + expect(screen.getByText(/90%/)).toBeInTheDocument(); + }); + + it('renders over-count fraud / AI checklist', () => { + render( + + ); + expect(screen.getByText(/lebih tinggi dari stock manual/i)).toBeInTheDocument(); + expect(screen.getByText(/fraud/i)).toBeInTheDocument(); + expect(screen.getByText(/akurasi 94,3%/i)).toBeInTheDocument(); + expect(screen.queryByText(/106%/)).not.toBeInTheDocument(); + }); +}); diff --git a/components/counting/__tests__/DataManagementPage.test.tsx b/components/counting/__tests__/DataManagementPage.test.tsx index 6289100..3be2131 100644 --- a/components/counting/__tests__/DataManagementPage.test.tsx +++ b/components/counting/__tests__/DataManagementPage.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import DataManagementPage from '../DataManagementPage.tsx'; import { api } from '../../../services/apiClient.ts'; @@ -76,6 +76,7 @@ vi.mock('../../../utils/format.ts', async () => { describe('DataManagementPage', () => { beforeEach(() => { + vi.mocked(api.kpis.list).mockResolvedValue([]); vi.mocked(api.countings.list).mockResolvedValue([]); vi.mocked(api.countings.sync).mockResolvedValue({ upserted: 2, @@ -123,4 +124,57 @@ describe('DataManagementPage', () => { 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([ + { + id: 1, + cycle: 1, + kandang: 1, + flock_id: 1, + date: '2026-08-12', + age: 2, + mortality: 0, + mortality_total: 10, + feed: 0, + feed_total: 0, + harvest: 0, + harvest_total: 0, + harvest_weight: 0, + harvest_weight_total: 0, + chicken_life: 19990, + chicken_life_percentage: 99.95, + iot_weight: 0, + average_harvest_day: 0, + average_harvest_weight: 0, + fcr: 0, + eef: 0, + cc: 0, + cw: 0, + fs: 0, + manual: 0, + created_at: '', + updated_at: '', + }, + ]); + vi.mocked(api.countings.list).mockResolvedValue([ + { + id: 1, + cycle: 1, + kandang: 1, + flock_id: 1, + date: '2026-08-12', + total_count: 18000, + mortality_count: 0, + created_at: '', + updated_at: '', + }, + ]); + + render( undefined} />); + + const alert = await screen.findByRole('alert'); + expect(within(alert).getByText(/IoT lebih rendah dari stock/i)).toBeInTheDocument(); + expect(within(alert).getByText(/Hari 2/i)).toBeInTheDocument(); + }); }); diff --git a/utils/__tests__/countingAccuracyWarning.test.ts b/utils/__tests__/countingAccuracyWarning.test.ts new file mode 100644 index 0000000..8079117 --- /dev/null +++ b/utils/__tests__/countingAccuracyWarning.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest'; +import type { ChickenCounting, Cycle, KPI, ManualInput } from '../../types/api.ts'; +import { getCountingAccuracyWarning } from '../countingAccuracyWarning.ts'; + +const cycle = { + id: 1, + kandang: 1, + kandang_name: 'K1', + total_days: 35, + current_day: 5, + visible_through_date: '2026-08-15', + start_date: '2026-08-10', + end_date: '2026-09-13', + chick_in_weight: 45, + doc_in_count: 1000, + feed_initial_balance: 0, + status: 'active', + created_at: '', + updated_at: '', +} satisfies Cycle; + +function kpi(partial: Partial & Pick): KPI { + return { + id: 1, + cycle: 1, + kandang: 1, + flock_id: 1, + age: 0, + mortality: 0, + mortality_total: 0, + feed: 0, + feed_total: 0, + harvest: 0, + harvest_total: 0, + harvest_weight: 0, + harvest_weight_total: 0, + chicken_life_percentage: 100, + iot_weight: 0, + average_harvest_day: 0, + average_harvest_weight: 0, + fcr: 0, + eef: 0, + cc: 0, + cw: 0, + fs: 0, + manual: 0, + created_at: '', + updated_at: '', + ...partial, + }; +} + +function counting(partial: Partial & Pick): ChickenCounting { + return { + id: 1, + cycle: 1, + kandang: 1, + flock_id: 1, + mortality_count: 0, + created_at: '', + updated_at: '', + ...partial, + }; +} + +describe('getCountingAccuracyWarning', () => { + it('returns null when there is no IoT counting', () => { + expect( + getCountingAccuracyWarning({ + cycle, + countings: [], + kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })], + manualInputs: [], + }) + ).toBeNull(); + }); + + it('returns under when latest IoT is below stock and accuracy <= 95', () => { + const result = getCountingAccuracyWarning({ + cycle, + countings: [counting({ date: '2026-08-10', total_count: 930 })], + kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })], + manualInputs: [], + }); + expect(result).toMatchObject({ + day: 0, + date: '2026-08-10', + iotCount: 930, + stockAkhir: 1000, + direction: 'under', + }); + expect(result!.accuracy).toBe(93); + expect(result!.ratioPercent).toBe(93); + }); + + it('returns over when latest IoT is above stock and accuracy <= 95', () => { + // 1060/1000 → ratio 106%, accuracy min/max = 1000/1060 ≈ 94.3% + const result = getCountingAccuracyWarning({ + cycle, + countings: [counting({ date: '2026-08-10', total_count: 1060 })], + kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })], + manualInputs: [], + }); + expect(result).toMatchObject({ + day: 0, + iotCount: 1060, + stockAkhir: 1000, + direction: 'over', + }); + expect(result!.accuracy).toBe(94.3); + expect(result!.ratioPercent).toBe(106); + }); + + it('warns when accuracy is exactly 95', () => { + const result = getCountingAccuracyWarning({ + cycle, + countings: [counting({ date: '2026-08-10', total_count: 950 })], + kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })], + manualInputs: [], + }); + expect(result).toMatchObject({ accuracy: 95, direction: 'under' }); + }); + + it('returns null when accuracy is above 95', () => { + expect( + getCountingAccuracyWarning({ + cycle, + countings: [counting({ date: '2026-08-10', total_count: 960 })], + kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })], + manualInputs: [], + }) + ).toBeNull(); + }); + + it('uses the latest IoT day even if an older day is worse', () => { + const result = getCountingAccuracyWarning({ + cycle, + countings: [ + counting({ id: 1, date: '2026-08-10', total_count: 800 }), // worse under + counting({ id: 2, date: '2026-08-12', total_count: 970 }), // latest, accuracy 97% → no warn + ], + kpis: [ + kpi({ date: '2026-08-10', chicken_life: 1000 }), + kpi({ date: '2026-08-11', chicken_life: 1000 }), + kpi({ date: '2026-08-12', chicken_life: 1000 }), + ], + manualInputs: [], + }); + expect(result).toBeNull(); + }); + + it('warns from the latest IoT day when that day is low', () => { + const result = getCountingAccuracyWarning({ + cycle, + countings: [ + counting({ id: 1, date: '2026-08-10', total_count: 990 }), // fine + counting({ id: 2, date: '2026-08-12', total_count: 900 }), // latest under + ], + kpis: [ + kpi({ date: '2026-08-10', chicken_life: 1000 }), + kpi({ date: '2026-08-11', chicken_life: 1000 }), + kpi({ date: '2026-08-12', chicken_life: 1000 }), + ], + manualInputs: [], + }); + expect(result).toMatchObject({ + day: 2, + date: '2026-08-12', + iotCount: 900, + direction: 'under', + }); + }); + + it('derives stock akhir from chain when chicken_life is missing', () => { + const manuals: ManualInput[] = [ + { + id: 1, + cycle: 1, + kandang: 1, + flock_id: 1, + date: '2026-08-10', + age_manual: 0, + mortality_manual: 50, + mortality_manual_total: 50, + feed_in_manual: 0, + feed_use_manual: 0, + feed_in_manual_total: 0, + feed_use_manual_total: 0, + feed_out_manual: 0, + feed_out_manual_total: 0, + harvest_manual: 0, + harvest_manual_total: 0, + harvest_weight_manual: 0, + harvest_weight_manual_total: 0, + manual_weight: 0, + average_harvest_day_manual: 0, + average_harvest_weight_manual: 0, + fcr_manual: 0, + eef_manual: 0, + created_at: '', + updated_at: '', + }, + ]; + const result = getCountingAccuracyWarning({ + cycle, + countings: [counting({ date: '2026-08-10', total_count: 900 })], + kpis: [], + manualInputs: manuals, + }); + // stock = 1000 - 50 = 950; IoT 900 → under, accuracy 900/950 ≈ 94.7 + expect(result).toMatchObject({ + stockAkhir: 950, + iotCount: 900, + direction: 'under', + }); + }); +}); diff --git a/utils/countingAccuracyWarning.ts b/utils/countingAccuracyWarning.ts new file mode 100644 index 0000000..da83dc6 --- /dev/null +++ b/utils/countingAccuracyWarning.ts @@ -0,0 +1,96 @@ +import type { ChickenCounting, Cycle, KPI, ManualInput } from '../types/api.ts'; +import { addDaysIso, cycleDayForDate } from './format.ts'; + +export const COUNTING_ACCURACY_WARNING_THRESHOLD = 95; + +export type CountingAccuracyWarning = { + day: number; + date: string; + iotCount: number; + stockAkhir: number; + accuracy: number; + ratioPercent: number; + direction: 'over' | 'under'; +}; + +export type CountingAccuracyWarningInput = { + cycle: Pick; + countings: ChickenCounting[]; + kpis: KPI[]; + manualInputs: ManualInput[]; +}; + +function round1(value: number): number { + return Math.round(value * 10) / 10; +} + +function matchAccuracy(iot: number, stockAkhir: number): number { + return round1((Math.min(iot, stockAkhir) / Math.max(iot, stockAkhir)) * 100); +} + +/** Build stock-akhir by date using the same rules as Hitung Ayam. */ +function stockAkhirByDate( + cycle: CountingAccuracyWarningInput['cycle'], + kpis: KPI[], + manualInputs: ManualInput[] +): Map { + const kpiByDate = new Map(kpis.map((k) => [k.date, k])); + const manualByDate = new Map(manualInputs.map((m) => [m.date, m])); + + const lastCycleDay = Math.max(0, (cycle.total_days || 1) - 1); + const endDay = cycleDayForDate(cycle.start_date, cycle.end_date); + const maxDay = Math.min(lastCycleDay, endDay); + + const result = new Map(); + let previousStockAkhir = 0; + + for (let day = 0; day <= maxDay; day++) { + const date = addDaysIso(cycle.start_date, day); + const kpi = kpiByDate.get(date); + const manual = manualByDate.get(date); + const stockAwal = day === 0 ? cycle.doc_in_count || 0 : previousStockAkhir; + const mati = manual?.mortality_manual ?? kpi?.mortality ?? 0; + const panen = manual?.harvest_manual ?? kpi?.harvest ?? 0; + const stockAkhir = kpi?.chicken_life ?? Math.max(0, stockAwal - mati - panen); + result.set(date, stockAkhir); + previousStockAkhir = stockAkhir; + } + + return result; +} + +function latestIotCounting(countings: ChickenCounting[]): ChickenCounting | null { + let best: ChickenCounting | null = null; + for (const row of countings) { + if (row.total_count == null) continue; + if (!best || row.date > best.date) best = row; + } + return best; +} + +export function getCountingAccuracyWarning( + input: CountingAccuracyWarningInput +): CountingAccuracyWarning | null { + const latest = latestIotCounting(input.countings); + if (!latest) return null; + + const stockMap = stockAkhirByDate(input.cycle, input.kpis, input.manualInputs); + const stockAkhir = stockMap.get(latest.date); + if (stockAkhir == null || stockAkhir <= 0) return null; + + const iotCount = latest.total_count; + if (iotCount === stockAkhir) return null; + + const accuracy = matchAccuracy(iotCount, stockAkhir); + if (accuracy > COUNTING_ACCURACY_WARNING_THRESHOLD) return null; + + return { + day: cycleDayForDate(input.cycle.start_date, latest.date), + date: latest.date, + iotCount, + stockAkhir, + accuracy, + ratioPercent: round1((iotCount / stockAkhir) * 100), + direction: iotCount > stockAkhir ? 'over' : 'under', + }; +}