first commit
This commit is contained in:
commit
b54624be96
226 files changed
+108840
No files matched your search
@@ -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;
|
||||
Reference in new issue
Block a user