import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { api, errorMessage } from '../../services/apiClient.ts'; import type { 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, cycleLastDayIndex, formatDateId, formatDateRange, formatNumber, resolveCycleStatus, todayIso, } from '../../utils/format.ts'; import { buildCountingInsightContext } from '../../utils/insightContextBuilders.ts'; import PopulationTrendChart from './PopulationTrendChart.tsx'; import CountingAccuracyWarningBanner from './CountingAccuracyWarningBanner.tsx'; import { getCountingAccuracyWarning } from '../../utils/countingAccuracyWarning.ts'; import { stockAkhirFromKpi } from '../../utils/kpiStock.ts'; import { PageAiInsight } from '../shared/PageAiInsight.tsx'; 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([]); const [countings, setCountings] = useState([]); const [manualRows, setManualRows] = useState([]); const [loading, setLoading] = useState(false); const [syncing, setSyncing] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [searchDay, setSearchDay] = useState(''); 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] = await Promise.all([ api.kpis.list({ cycle_id: cycleId }), api.countings.list({ cycle_id: cycleId }), api.manualInputs.list({ cycle_id: cycleId }), ]); setKpis(k); setCountings(c); setManualRows(m); } catch (err) { setError(errorMessage(err)); } finally { setLoading(false); } }, []); useEffect(() => { if (!selectedCycle) { setKpis([]); setCountings([]); setManualRows([]); 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(() => { 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 = cycleLastDayIndex(selectedCycle); 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 = stockAkhirFromKpi(kpi) ?? 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 > 0 ? (totalMortality / initialPopulation) * 100 : null; return { initialPopulation, totalMortality, currentPopulation, totalPanen, totalBeratPanen, mortalityRate, }; }, [rows, selectedCycle]); const insightContext = useMemo(() => { if (!selectedKandang || !selectedCycle) return null; // Cap insight history to last day with KPI (or counting) data — calendar // padding can extend past days that actually have operational figures. const lastKpiAge = kpis.length > 0 ? Math.max(...kpis.map((k) => k.age)) : null; const lastCountingDay = countings.length > 0 ? Math.max( ...countings.map((c) => cycleDayForDate(selectedCycle.start_date, c.date)) ) : null; const lastDataDay = lastKpiAge != null || lastCountingDay != null ? Math.max(lastKpiAge ?? 0, lastCountingDay ?? 0) : null; const insightRows = lastDataDay != null ? rows.filter((row) => row.day <= lastDataDay) : rows; return buildCountingInsightContext({ kandangId: selectedKandang.id, kandangName: selectedKandang.kandang_name, cycleId: selectedCycle.id, totalDays: selectedCycle.total_days ?? null, dailyRows: insightRows, populasi_awal_ekor: stats.initialPopulation > 0 ? stats.initialPopulation : null, populasi_kini_ekor: stats.currentPopulation, mortalitas_kumulatif_ekor: stats.totalMortality, mortalitas_persen: stats.mortalityRate, panen_kumulatif_ekor: stats.totalPanen, berat_panen_kumulatif_kg: stats.totalBeratPanen, }); }, [selectedKandang, selectedCycle, rows, stats, kpis, countings]); 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 -; 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 ( {persentase.toFixed(1)}% ); }; 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 ( {akurasi.toFixed(1)}% ); }; if (!selectedCycle) return ; return (
{/* Header */} } subtitle={`Lihat data deplesi harian untuk siklus produksi · ${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`} actions={ } /> {countingAccuracyWarning && ( )} {selectedKandang && insightContext ? (
) : (

AI Insight

Pilih kandang untuk melihat dan generate AI Insight.
)} {/* Population Chart */}
{error && (
{error}
)} {notice && (
{notice}
)} {/* Statistics Cards */}

Populasi Awal

{formatNumber(stats.initialPopulation, 0)}

Populasi Saat Ini

{formatNumber(stats.currentPopulation, 0)}

Total Mortalitas

{formatNumber(stats.totalMortality, 0)}

Total Panen

{formatNumber(stats.totalPanen, 0)} ekor

{formatNumber(stats.totalBeratPanen, 2)} kg

Tingkat Mortalitas

{stats.mortalityRate == null ? 'N/A' : `${stats.mortalityRate.toFixed(2)}%`}

{loading ? ( ) : ( <> {/* Filters and Sorting */}
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" />
{isClosedCycle && (

Mode Lihat Saja (Siklus Selesai)

)} {/* Data Table */}
{filteredRecords.length === 0 ? (
Tidak ada data yang ditemukan
) : ( 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 (
Hari {record.day} {record.isToday && ( Hari ini )} {record.isFuture && ( Mendatang )} {record.isEdited && ( Diubah )}
{record.dateLabel}
Stock awal
{formatNumber(record.stockAwal, 0)}
Mati
{formatNumber(record.mati, 0)}
Mati IoT
{formatNumber(record.matiIot, 0)}
Akurasi Mati
{getAkurasiMatiBadge(record.akurasiMati)}
Panen
{formatNumber(record.panen, 0)}
Berat panen (kg)
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'} kg
Stock akhir
{formatNumber(record.stockAkhir, 0)}
Chicken Counting
{record.chickenCount !== undefined ? formatNumber(record.chickenCount, 0) : '-'}
Persentase
{getPersentaseBadge(record.persentase)}
); }) )}
{/* Top header row with grouped columns */} {/* Second header row with individual column names */} {filteredRecords.length === 0 ? ( ) : ( 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 ( {/* Hari */} {/* Tanggal */} {/* Stock awal */} {/* Mati */} {/* Mati IoT */} {/* Akurasi Mati */} {/* JUMLAH PANEN */} {/* BERAT PANEN */} {/* Stock akhir */} {/* Chicken Counting */} {/* Persentase */} ); }) )}
Hari Tanggal Deplesi Daily PANEN Stock akhir Chicken Counting (IOT) Persentase
Stock awal Mati Mati IoT Akurasi Mati JUMLAH PANEN BERAT PANEN (KG)
Tidak ada data yang ditemukan
{record.day} {record.dateLabel} {formatNumber(record.stockAwal, 0)} {formatNumber(record.mati, 0)} {formatNumber(record.matiIot, 0)} {getAkurasiMatiBadge(record.akurasiMati)} {formatNumber(record.panen, 0)} {record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'} {formatNumber(record.stockAkhir, 0)} {record.chickenCount !== undefined ? formatNumber(record.chickenCount, 0) : '-'} {getPersentaseBadge(record.persentase)}
{/* Info Section */}

Informasi

  • Data hanya ditampilkan hingga hari ini (dihitung dari tanggal mulai siklus)
  • Hari 0: Stock awal = Jumlah DOC In (ayam masuk)
  • Hari 1+: Stock awal = Stock akhir hari sebelumnya
  • Stock akhir = Stock awal - Mati - panen (otomatis terhitung)
  • Mati: Mortalitas harian manual | Mati IoT:{' '} mortalitas dari edge API (kosong = 0)
  • Akurasi Mati = (nilai kecil / nilai besar) × 100% antara Mati manual dan Mati IoT (keduanya 0 → 100%)
  • panen: Ayam dipanen
  • Persentase = (nilai kecil / nilai besar) × 100% untuk konsep akurasi hitungan IOT terhadap stock akhir manual
  • Chicken Counting (IOT) = hasil hitung otomatis dari IOT
  • Baris dengan border ungu menandakan ada panen pada hari tersebut
)}
); }; export default DataManagementPage;