first commit

This commit is contained in:
Alberto-Audrix committed 2026-09-08 15:21:23 +07:00
commit b54624be96
226 files changed
+108840

No files matched your search

@@ -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<Props> = ({ 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 (
<div
role="alert"
className="mb-6 rounded-lg border-l-4 border-amber-500 bg-amber-50 p-4 text-amber-950"
>
<div className="flex items-start gap-3">
<i className="fa-solid fa-triangle-exclamation mt-0.5 text-xl text-amber-600" aria-hidden />
<div className="min-w-0 space-y-1">
<p className="text-sm font-semibold">{title}</p>
<p className="text-sm">
Hari {warning.day} · IoT {formatNumber(warning.iotCount, 0)} vs stock{' '}
{formatNumber(warning.stockAkhir, 0)} (akurasi {formatNumber(warning.accuracy, 1)}%)
</p>
<p className="text-sm">{body}</p>
</div>
</div>
</div>
);
};
export default CountingAccuracyWarningBanner;
+850
View File
@@ -0,0 +1,850 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { api, errorMessage } from '../../services/apiClient.ts';
import type { AIInsight, ChickenCounting, KPI, ManualInput } from '../../types/api.ts';
import { useFarm } from '../../context/FarmContext.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from '../ui/Feedback.tsx';
import {
addDaysIso,
cycleDayForDate,
formatDateId,
formatDateRange,
formatNumber,
resolveCycleStatus,
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;
date: string;
dateLabel: string;
stockAwal: number;
mati: number;
matiIot: number;
akurasiMati: number;
panen: number;
beratPanen: number;
stockAkhir: number;
chickenCount?: number;
persentase: number;
mortalityTotal: number;
harvestTotal: number;
harvestWeightTotal: number;
isEdited: boolean;
isToday: boolean;
isFuture: boolean;
};
function mortalityAccuracy(manual: number, iot: number): number {
if (manual === 0 && iot === 0) return 100;
const denom = Math.max(manual, iot);
if (denom <= 0) return 100;
return Math.round((Math.min(manual, iot) / denom) * 1000) / 10;
}
const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
const [kpis, setKpis] = useState<KPI[]>([]);
const [countings, setCountings] = useState<ChickenCounting[]>([]);
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');
const isClosedCycle = !selectedCycle || resolveCycleStatus(selectedCycle) !== 'active';
const loadData = useCallback(async (cycleId: number) => {
setLoading(true);
setError(null);
try {
const [k, c, m, i] = await Promise.all([
api.kpis.list({ cycle_id: cycleId }),
api.countings.list({ cycle_id: cycleId }),
api.manualInputs.list({ cycle_id: cycleId }),
api.insights.list({ cycle_id: cycleId }),
]);
setKpis(k);
setCountings(c);
setManualRows(m);
setInsights(i);
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!selectedCycle) {
setKpis([]);
setCountings([]);
setManualRows([]);
setInsights([]);
return;
}
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 [];
const countingByDate = new Map(countings.map((c) => [c.date, c]));
const manualByDate = new Map(manualRows.map((m) => [m.date, m]));
const kpiByDate = new Map(kpis.map((k) => [k.date, k]));
const lastCycleDay = Math.max(0, (selectedCycle.total_days || 1) - 1);
const endDay = cycleDayForDate(selectedCycle.start_date, selectedCycle.end_date);
const calendarDay = cycleDayForDate(selectedCycle.start_date, todayIso());
const maxDay = Math.min(calendarDay, lastCycleDay, endDay);
const today = todayIso();
let previousStockAkhir = 0;
let cumulativeMortality = 0;
let cumulativeHarvest = 0;
let cumulativeHarvestWeight = 0;
const result: Row[] = [];
for (let day = 0; day <= maxDay; day++) {
const date = addDaysIso(selectedCycle.start_date, day);
const kpi = kpiByDate.get(date);
const manual = manualByDate.get(date);
const counting = countingByDate.get(date);
const stockAwal = day === 0 ? selectedCycle.doc_in_count || 0 : previousStockAkhir;
const mati = manual?.mortality_manual ?? kpi?.mortality ?? 0;
const matiIot = counting?.mortality_count ?? 0;
const akurasiMati = mortalityAccuracy(mati, matiIot);
const panen = manual?.harvest_manual ?? kpi?.harvest ?? 0;
// Database menyimpan berat panen dalam gram; tampilkan selalu dalam kg.
const beratPanen = (manual?.harvest_weight_manual ?? kpi?.harvest_weight ?? 0) / 1000;
const stockAkhir = kpi?.chicken_life ?? Math.max(0, stockAwal - mati - panen);
const chickenCount = counting?.total_count;
// Akurasi Chicken Counting (IOT) terhadap stock akhir manual:
// (nilai kecil / nilai besar) × 100%. Tanpa data IOT, tetap 0 (tampil "-").
const persentase =
chickenCount !== undefined && chickenCount !== null && stockAkhir > 0
? (Math.min(chickenCount, stockAkhir) / Math.max(chickenCount, stockAkhir)) * 100
: 0;
cumulativeMortality += mati;
cumulativeHarvest += panen;
cumulativeHarvestWeight += beratPanen;
result.push({
day,
date,
dateLabel: formatDateId(date),
stockAwal,
mati,
matiIot,
akurasiMati,
panen,
beratPanen,
stockAkhir,
chickenCount,
persentase: Math.round(persentase * 10) / 10,
mortalityTotal: kpi?.mortality_total ?? cumulativeMortality,
harvestTotal: kpi?.harvest_total ?? cumulativeHarvest,
harvestWeightTotal:
kpi?.harvest_weight_total !== undefined
? kpi.harvest_weight_total / 1000
: cumulativeHarvestWeight,
isEdited: !!manual,
isToday: date === today,
isFuture: date > today,
});
previousStockAkhir = stockAkhir;
}
return result;
}, [kpis, countings, manualRows, selectedCycle]);
const stats = useMemo(() => {
const initialPopulation = selectedCycle?.doc_in_count || 0;
const latest = rows[rows.length - 1];
const totalMortality = latest?.mortalityTotal ?? 0;
const totalPanen = latest?.harvestTotal ?? 0;
const totalBeratPanen = latest?.harvestWeightTotal ?? 0;
const currentPopulation = latest?.stockAkhir ?? initialPopulation;
const mortalityRate = initialPopulation
? ((totalMortality / initialPopulation) * 100).toFixed(2)
: '0.00';
return {
initialPopulation,
totalMortality,
currentPopulation,
totalPanen,
totalBeratPanen,
mortalityRate,
};
}, [rows, selectedCycle]);
const chartData = useMemo(
() =>
rows.map((r) => ({
date: r.dateLabel,
population: r.stockAkhir,
chickenCount: r.chickenCount ?? undefined,
})),
[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) {
const searchNum = parseInt(searchDay, 10);
if (!Number.isNaN(searchNum)) {
filtered = filtered.filter((r) => r.day === searchNum);
}
}
return [...filtered].sort((a, b) => (sortOrder === 'asc' ? a.day - b.day : b.day - a.day));
}, [rows, searchDay, sortOrder]);
const getPersentaseBadge = (persentase: number) => {
if (!persentase) return <span className="text-gray-400">-</span>;
const isGreen = persentase >= 95;
const isYellow = persentase >= 90 && persentase < 95;
const colorClass = isGreen
? 'bg-green-100 text-green-700'
: isYellow
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700';
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
{persentase.toFixed(1)}%
</span>
);
};
const getAkurasiMatiBadge = (akurasi: number) => {
const isGreen = akurasi >= 95;
const isYellow = akurasi >= 90 && akurasi < 95;
const colorClass = isGreen
? 'bg-green-100 text-green-700'
: isYellow
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700';
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
{akurasi.toFixed(1)}%
</span>
);
};
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
return (
<div>
{/* Header */}
<PageHeader
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 && (
<CountingAccuracyWarningBanner warning={countingAccuracyWarning} />
)}
{/* AI Insight */}
<section className="mb-6">
<h2 className="text-lg font-bold text-gray-800 mb-3">
<i className="fa-solid fa-wand-magic-sparkles text-purple-500 mr-2"></i>
AI Insight
</h2>
{insights.length === 0 ? (
<Banner>Belum ada insight untuk siklus ini.</Banner>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{insights.slice(0, 4).map((item) => (
<article
key={item.id}
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">{item.section}</p>
<h3 className="font-semibold text-gray-800 mt-1">{item.alert || 'Insight'}</h3>
</div>
<span className="text-xs text-gray-400">{item.session}</span>
</div>
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
{item.insight_text}
</p>
</article>
))}
</div>
)}
</section>
{/* Population Chart */}
<div className="mb-6">
<PopulationTrendChart
data={chartData}
title={`Tren Populasi - ${selectedKandang?.kandang_name || ''}`}
/>
</div>
{error && (
<div className="mb-4">
<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">
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-blue-500">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-600 mb-1">Populasi Awal</p>
<p className="text-xl font-bold text-gray-800">
{formatNumber(stats.initialPopulation, 0)}
</p>
</div>
<i className="fa-solid fa-egg text-blue-500 text-2xl"></i>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-green-500">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-600 mb-1">Populasi Saat Ini</p>
<p className="text-xl font-bold text-green-600">
{formatNumber(stats.currentPopulation, 0)}
</p>
</div>
<i className="fa-solid fa-drumstick-bite text-green-500 text-2xl"></i>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-red-500">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-600 mb-1">Total Mortalitas</p>
<p className="text-xl font-bold text-red-600">
{formatNumber(stats.totalMortality, 0)}
</p>
</div>
<i className="fa-solid fa-heart-crack text-red-500 text-2xl"></i>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-purple-500">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-600 mb-1">Total Panen</p>
<p className="text-xl font-bold text-purple-700">
{formatNumber(stats.totalPanen, 0)} ekor
</p>
<p className="text-sm text-purple-600 mt-1">
{formatNumber(stats.totalBeratPanen, 2)} kg
</p>
</div>
<i className="fa-solid fa-box text-purple-500 text-2xl"></i>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-orange-500">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-600 mb-1">Tingkat Mortalitas</p>
<p className="text-xl font-bold text-orange-600">{stats.mortalityRate}%</p>
</div>
<i className="fa-solid fa-chart-line text-orange-500 text-2xl"></i>
</div>
</div>
</div>
{loading ? (
<Spinner />
) : (
<>
{/* Filters and Sorting */}
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-2">
<i className="fa-solid fa-search mr-2"></i>Cari Hari
</label>
<input
type="number"
value={searchDay}
onChange={(e) => setSearchDay(e.target.value)}
placeholder="Masukkan nomor hari..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex items-end">
<div className="flex gap-2">
<button
onClick={() => setSortOrder('asc')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
sortOrder === 'asc'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<i className="fa-solid fa-arrow-up mr-2"></i>
Terlama
</button>
<button
onClick={() => setSortOrder('desc')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
sortOrder === 'desc'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<i className="fa-solid fa-arrow-down mr-2"></i>
Terbaru
</button>
</div>
</div>
</div>
</div>
{isClosedCycle && (
<div className="mb-6 bg-amber-50 border-l-4 border-amber-400 rounded-lg p-4">
<div className="flex items-center">
<i className="fa-solid fa-eye text-amber-500 text-xl mr-4"></i>
<div>
<p className="text-sm text-amber-800 font-semibold">
Mode Lihat Saja (Siklus Selesai)
</p>
</div>
</div>
</div>
)}
{/* Data Table */}
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<div className="space-y-4 p-4 md:hidden">
{filteredRecords.length === 0 ? (
<div className="rounded-xl border border-dashed border-gray-200 bg-gray-50 px-6 py-12 text-center text-gray-500">
<i className="fa-solid fa-inbox text-4xl mb-3 block text-gray-300"></i>
Tidak ada data yang ditemukan
</div>
) : (
filteredRecords.map((record) => {
const cardTone = record.isToday
? 'ring-2 ring-blue-200 bg-blue-50/40'
: record.panen > 0
? 'ring-1 ring-purple-200 bg-white'
: 'bg-white';
return (
<article
key={record.day}
className={`rounded-2xl border border-gray-200 shadow-sm overflow-hidden ${cardTone}`}
>
<div className="flex items-start justify-between gap-3 px-4 py-4 bg-gray-50/80">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-blue-600 px-3 py-1 text-xs font-semibold text-white">
Hari {record.day}
</span>
{record.isToday && (
<span className="rounded-full bg-blue-100 px-2.5 py-1 text-[11px] font-semibold text-blue-700">
Hari ini
</span>
)}
{record.isFuture && (
<span className="rounded-full bg-gray-200 px-2.5 py-1 text-[11px] font-semibold text-gray-700">
Mendatang
</span>
)}
{record.isEdited && (
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-[11px] font-semibold text-amber-700">
Diubah
</span>
)}
</div>
<div className="mt-2 text-sm font-medium text-gray-700">
{record.dateLabel}
</div>
</div>
</div>
<div className="p-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-slate-50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
Stock awal
</div>
<div className="text-sm font-semibold text-slate-800">
{formatNumber(record.stockAwal, 0)}
</div>
</div>
<div className="rounded-xl bg-red-50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-red-500">
Mati
</div>
<div className="text-[11px] font-semibold text-red-600 md:text-sm">
{formatNumber(record.mati, 0)}
</div>
</div>
<div className="rounded-xl bg-rose-50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-rose-500">
Mati IoT
</div>
<div className="text-[11px] font-semibold text-rose-600 md:text-sm">
{formatNumber(record.matiIot, 0)}
</div>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-emerald-600">
Akurasi Mati
</div>
<div className="mt-1">{getAkurasiMatiBadge(record.akurasiMati)}</div>
</div>
<div className="rounded-xl bg-purple-50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-purple-500">
Panen
</div>
<div className="text-[11px] font-semibold text-purple-600 md:text-sm">
{formatNumber(record.panen, 0)}
</div>
</div>
<div className="rounded-xl bg-purple-50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-purple-500">
Berat panen (kg)
</div>
<div className="text-[11px] font-semibold text-purple-600 md:text-sm">
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'} kg
</div>
</div>
<div className="rounded-xl bg-slate-50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
Stock akhir
</div>
<div className="text-[11px] font-semibold text-slate-900 md:text-sm">
{formatNumber(record.stockAkhir, 0)}
</div>
</div>
<div className="rounded-xl bg-blue-50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-blue-500">
Chicken Counting
</div>
<div className="text-[11px] font-semibold text-blue-600 md:text-sm">
{record.chickenCount !== undefined
? formatNumber(record.chickenCount, 0)
: '-'}
</div>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-emerald-600">
Persentase
</div>
<div className="mt-1">{getPersentaseBadge(record.persentase)}</div>
</div>
</div>
</div>
</article>
);
})
)}
</div>
<div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-[1280px] lg:min-w-[1380px] w-full border-collapse table-auto">
<thead className="bg-gray-50">
{/* Top header row with grouped columns */}
<tr className="border-b border-gray-300">
<th
rowSpan={2}
className="w-16 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 border-r border-gray-300"
>
Hari
</th>
<th
rowSpan={2}
className="w-24 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 border-r border-gray-300"
>
Tanggal
</th>
<th
colSpan={4}
className="px-2 py-2 text-center text-xs font-bold uppercase text-white bg-blue-400 border-r border-gray-300"
>
Deplesi Daily
</th>
<th
colSpan={2}
className="px-2 py-2 text-center text-xs font-bold uppercase text-white bg-purple-400 border-r border-gray-300"
>
PANEN
</th>
<th
rowSpan={2}
className="w-24 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 border-r border-gray-300"
>
Stock akhir
</th>
<th
rowSpan={2}
className="w-28 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 border-r border-gray-300"
>
Chicken Counting (IOT)
</th>
<th
rowSpan={2}
className="w-20 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700"
>
Persentase
</th>
</tr>
{/* Second header row with individual column names */}
<tr className="border-b border-gray-200">
<th className="w-24 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-blue-50 border-r border-gray-300">
Stock awal
</th>
<th className="w-16 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-blue-50 border-r border-gray-300">
Mati
</th>
<th className="w-16 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-blue-50 border-r border-gray-300">
Mati IoT
</th>
<th className="w-20 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-blue-50 border-r border-gray-300">
Akurasi Mati
</th>
<th className="w-20 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-purple-50 border-r border-gray-300">
JUMLAH PANEN
</th>
<th className="w-24 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-purple-50 border-r border-gray-300">
BERAT PANEN (KG)
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{filteredRecords.length === 0 ? (
<tr>
<td
colSpan={11}
className="px-6 py-12 text-center text-gray-500"
>
<i className="fa-solid fa-inbox text-4xl mb-3 block text-gray-300"></i>
Tidak ada data yang ditemukan
</td>
</tr>
) : (
filteredRecords.map((record, index) => {
const rowClass = `hover:bg-gray-100 transition-colors ${
index % 2 === 0 ? 'bg-white' : 'bg-gray-50'
} ${record.panen > 0 ? 'border-l-4 border-purple-400' : ''}`;
return (
<tr key={record.day} className={rowClass}>
{/* Hari */}
<td className="px-2 py-2 whitespace-nowrap border-r border-gray-200 text-center">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 text-xs font-bold text-blue-800 md:h-9 md:w-9 md:text-sm">
{record.day}
</span>
</td>
{/* Tanggal */}
<td className="px-2 py-2 whitespace-nowrap border-r border-gray-200 text-center text-[11px] text-gray-700 md:text-sm">
{record.dateLabel}
</td>
{/* Stock awal */}
<td className="px-2 py-2 whitespace-nowrap border-r border-gray-200 text-center text-[11px] text-gray-500 md:text-sm">
{formatNumber(record.stockAwal, 0)}
</td>
{/* Mati */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
<span className="text-[11px] font-semibold text-red-600 md:text-sm">
{formatNumber(record.mati, 0)}
</span>
</td>
{/* Mati IoT */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
<span className="text-[11px] font-semibold text-rose-600 md:text-sm">
{formatNumber(record.matiIot, 0)}
</span>
</td>
{/* Akurasi Mati */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{getAkurasiMatiBadge(record.akurasiMati)}
</td>
{/* JUMLAH PANEN */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
{formatNumber(record.panen, 0)}
</span>
</td>
{/* BERAT PANEN */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'}
</span>
</td>
{/* Stock akhir */}
<td className="px-2 py-2 whitespace-nowrap text-center text-[11px] font-bold text-gray-800 border-r border-gray-200 md:text-sm">
{formatNumber(record.stockAkhir, 0)}
</td>
{/* Chicken Counting */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
<span className="text-[11px] font-semibold text-blue-600 md:text-sm">
{record.chickenCount !== undefined
? formatNumber(record.chickenCount, 0)
: '-'}
</span>
</td>
{/* Persentase */}
<td className="px-2 py-2 whitespace-nowrap text-center">
{getPersentaseBadge(record.persentase)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Info Section */}
<div className="mt-6 bg-blue-50 border-l-4 border-blue-500 rounded-lg p-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<i className="fa-solid fa-info-circle text-blue-500 text-2xl"></i>
</div>
<div className="ml-4">
<h3 className="text-sm font-semibold text-blue-800 mb-2">Informasi</h3>
<ul className="text-sm text-blue-700 space-y-1">
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>Data hanya ditampilkan hingga
hari ini (dihitung dari tanggal mulai siklus)
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>
<strong>Hari 0:</strong> Stock awal = Jumlah DOC In (ayam masuk)
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>
<strong>Hari 1+:</strong> Stock awal = Stock akhir hari sebelumnya
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>
<strong>Stock akhir</strong> = Stock awal - Mati - panen (otomatis terhitung)
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>
<strong>Mati:</strong> Mortalitas harian manual | <strong>Mati IoT:</strong>{' '}
mortalitas dari edge API (kosong = 0)
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>
<strong>Akurasi Mati</strong> = (nilai kecil / nilai besar) × 100% antara Mati
manual dan Mati IoT (keduanya 0 → 100%)
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>
<strong>panen:</strong> Ayam dipanen
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>
<strong>Persentase</strong> = (nilai kecil / nilai besar) × 100% untuk konsep
akurasi hitungan IOT terhadap stock akhir manual
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>
<strong>Chicken Counting (IOT)</strong> = hasil hitung otomatis dari IOT
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>Baris dengan border ungu
menandakan ada panen pada hari tersebut
</li>
</ul>
</div>
</div>
</div>
</>
)}
</div>
);
};
export default DataManagementPage;
@@ -0,0 +1,119 @@
import React from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { formatNumber } from '../../utils/format.ts';
export type PopulationPoint = {
date: string;
population: number;
chickenCount?: number;
};
type Props = {
data: PopulationPoint[];
title?: string;
};
function yTick(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}jt`;
if (value >= 1_000) return `${Math.round(value / 1_000)}rb`;
return `${value}`;
}
const PopulationTrendChart: React.FC<Props> = ({ data, title = 'Tren Populasi' }) => {
if (!data || data.length === 0) {
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h3 className="text-lg font-bold text-gray-800 mb-4">{title}</h3>
<div className="h-64 flex items-center justify-center text-gray-400">
<div className="text-center">
<i className="fa-solid fa-chart-line text-4xl mb-2"></i>
<p>Tidak ada data tersedia</p>
</div>
</div>
</div>
);
}
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h3 className="text-lg font-bold text-gray-800 mb-2">{title}</h3>
{/* Legend: explicit color explanation */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 mb-3 text-xs">
<span className="inline-flex items-center gap-2">
<span className="inline-block w-5 h-[3px] rounded-full bg-red-500"></span>
<span className="font-semibold text-red-700">Populasi (Stock Akhir Manual)</span>
</span>
<span className="inline-flex items-center gap-2">
<span className="inline-block w-5 h-[3px] rounded-full bg-blue-500"></span>
<span className="font-semibold text-blue-700">Chicken Counting (IOT)</span>
</span>
</div>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis
dataKey="date"
fontSize={11}
tick={{ fill: '#6b7280' }}
interval="preserveStartEnd"
/>
<YAxis fontSize={11} tick={{ fill: '#6b7280' }} tickFormatter={yTick} />
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #e0e0e0',
borderRadius: '8px',
padding: '8px 12px',
}}
formatter={(value, name) => {
const num = Number(value ?? 0);
return [num === 0 ? '0' : formatNumber(num, 0), name];
}}
labelStyle={{ fontWeight: 600, marginBottom: '4px' }}
/>
<Line
type="monotone"
dataKey="population"
stroke="#ef4444"
strokeWidth={2.5}
name="Populasi (Stock Akhir Manual)"
dot={{ r: 3, fill: '#ef4444' }}
activeDot={{ r: 5, strokeWidth: 2, stroke: '#fff' }}
/>
<Line
type="monotone"
dataKey="chickenCount"
stroke="#3b82f6"
strokeWidth={2.5}
name="Chicken Counting (IOT)"
dot={{ r: 3, fill: '#3b82f6' }}
activeDot={{ r: 5, strokeWidth: 2, stroke: '#fff' }}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
<p className="mt-3 text-xs text-gray-500">
<i className="fa-solid fa-circle-info mr-1 text-gray-400"></i>
Garis <span className="font-semibold text-red-700">merah</span> = populasi hasil perhitungan
manual (stock akhir), garis <span className="font-semibold text-blue-700">biru</span> =
hasil hitung otomatis sensor IOT. Semakin berdekatan kedua garis, semakin akurat hitungan
IOT.
</p>
</div>
);
};
export default PopulationTrendChart;
@@ -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(<CountingAccuracyWarningBanner warning={base} />);
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(
<CountingAccuracyWarningBanner
warning={{ ...base, iotCount: 1060, stockAkhir: 1000, accuracy: 94.3, ratioPercent: 106, direction: 'over' }}
/>
);
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();
});
});
@@ -0,0 +1,180 @@
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';
vi.mock('../../../services/apiClient.ts', () => ({
api: {
kpis: {
list: vi.fn().mockResolvedValue([]),
},
countings: {
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([
{
id: 1,
cycle: 1,
date: '2026-08-10',
mortality_manual: 10,
mortality_manual_total: 10,
harvest_manual: 0,
harvest_weight_manual: 0,
},
]),
create: vi.fn(),
},
insights: {
list: vi.fn().mockResolvedValue([]),
},
},
errorMessage: (err: unknown) => (err instanceof Error ? err.message : 'Terjadi kesalahan'),
}));
vi.mock('../../../context/FarmContext.tsx', () => ({
useFarm: () => ({
selectedSite: { id: 1, site_name: 'Sukawarna', user: 1, created_at: '', updated_at: '' },
selectedKandang: {
id: 1,
kandang_name: 'Kandang 1',
site: 1,
created_at: '',
updated_at: '',
},
selectedCycle: {
id: 1,
kandang: 1,
flock: 1,
total_days: 35,
current_day: 1,
visible_through_date: '2026-08-10',
start_date: '2026-08-10',
end_date: '2026-09-13',
chick_in_weight: 45,
doc_in_count: 20000,
doc_in_weight: 45,
status: 'gm',
created_at: '',
updated_at: '',
},
}),
}));
vi.mock('../../../utils/format.ts', async () => {
const actual = await vi.importActual<typeof import('../../../utils/format.ts')>(
'../../../utils/format.ts'
);
return {
...actual,
todayIso: () => '2026-08-12',
};
});
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 () => {
render(<DataManagementPage onSettings={() => undefined} />);
expect(await screen.findByRole('heading', { name: 'Hitung Ayam' })).toBeInTheDocument();
expect(screen.getByText(/Populasi Awal/i)).toBeInTheDocument();
expect(screen.getByText(/Total Mortalitas/i)).toBeInTheDocument();
expect(screen.getByText(/Tingkat Mortalitas/i)).toBeInTheDocument();
expect(screen.getAllByText(/Hari 0/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Hari 2/i).length).toBeGreaterThan(0);
});
it('does not offer manual or counting input forms', async () => {
render(<DataManagementPage onSettings={() => undefined} />);
expect(await screen.findByRole('heading', { name: 'Hitung Ayam' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /Input manual/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Simpan hitungan/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Edit/i })).not.toBeInTheDocument();
});
it('shows Mati IoT as 0 and Akurasi Mati when counting row is missing', async () => {
render(<DataManagementPage onSettings={() => undefined} />);
expect(await screen.findByRole('heading', { name: 'Hitung Ayam' })).toBeInTheDocument();
expect(screen.getAllByText('Mati IoT').length).toBeGreaterThan(0);
expect(screen.getAllByText('Akurasi Mati').length).toBeGreaterThan(0);
// manual 10 vs iot 0 → 0.0%
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([
{
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(<DataManagementPage onSettings={() => 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();
});
});