Files
dashboard-cpsp/components/counting/DataManagementPage.tsx
T

1101 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { 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,
formatDateId,
formatDateRange,
formatNumber,
} from '../../utils/format.ts';
import PopulationTrendChart from './PopulationTrendChart.tsx';
type Row = {
day: number;
date: string;
dateLabel: string;
stockAwal: number;
mati: number;
panen: number;
beratPanen: number;
stockAkhir: number;
chickenCount?: number;
persentase: number;
mortalityTotal: number;
harvestTotal: number;
harvestWeightTotal: number;
isEdited: boolean;
isToday: boolean;
isFuture: boolean;
};
type EditValues = {
mati: number;
panen: number;
beratPanen: string;
chickenCount?: number;
};
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 [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editingDay, setEditingDay] = useState<number | null>(null);
const [editValues, setEditValues] = useState<EditValues>({
mati: 0,
panen: 0,
beratPanen: '',
});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [searchDay, setSearchDay] = useState<string>('');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const isReadOnly = !selectedCycle || selectedCycle.status !== 'active';
const loadData = 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([]);
setEditingDay(null);
return;
}
void loadData(selectedCycle.id);
}, [selectedCycle]);
// Build daily rows for day 0..currentDay, merging KPI / chicken counting / manual input data.
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 maxDay = Math.min(selectedCycle.current_day ?? 0, lastCycleDay);
const visibleThrough = selectedCycle.visible_through_date;
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 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,
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 === visibleThrough,
isFuture: date > visibleThrough,
});
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 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 handleEdit = (record: Row) => {
setEditingDay(record.day);
setEditValues({
mati: record.mati,
panen: record.panen,
beratPanen: record.beratPanen ? record.beratPanen.toString() : '',
chickenCount: record.chickenCount,
});
setValidationErrors({});
};
const validateEditValues = (record: Row): boolean => {
const errors: Record<string, string> = {};
const beratPanenValue = editValues.beratPanen === '' ? 0 : parseFloat(editValues.beratPanen);
if (editValues.mati < 0) {
errors.mati = 'Mati tidak boleh negatif';
} else if (editValues.mati > record.stockAwal) {
errors.mati = `Mati tidak boleh lebih dari stock awal (${record.stockAwal})`;
}
if (editValues.panen < 0) {
errors.panen = 'Panen tidak boleh negatif';
} else if (editValues.panen > record.stockAwal - editValues.mati) {
errors.panen = `Panen tidak boleh lebih dari sisa (${record.stockAwal - editValues.mati})`;
}
if (editValues.chickenCount !== undefined && editValues.chickenCount < 0) {
errors.chickenCount = 'Chicken counting tidak boleh negatif';
}
if (Number.isNaN(beratPanenValue) || beratPanenValue < 0) {
errors.beratPanen = 'Berat panen tidak boleh negatif';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSave = async () => {
if (editingDay === null || !selectedCycle) return;
const record = rows.find((r) => r.day === editingDay);
if (!record) return;
if (!validateEditValues(record)) return;
setSaving(true);
try {
const beratPanenValue = editValues.beratPanen === '' ? 0 : parseFloat(editValues.beratPanen);
const manualChanged =
editValues.mati !== record.mati ||
editValues.panen !== record.panen ||
beratPanenValue !== record.beratPanen;
if (manualChanged) {
await api.manualInputs.create({
cycle: selectedCycle.id,
date: record.date,
age_manual: record.day,
mortality_manual: editValues.mati,
harvest_manual: editValues.panen,
// Input pengguna dalam kg, disimpan ke database dalam gram.
harvest_weight_manual: beratPanenValue * 1000,
});
}
if (
editValues.chickenCount !== undefined &&
editValues.chickenCount !== record.chickenCount
) {
const existing = countings.find((c) => c.date === record.date);
if (existing) {
await api.countings.update(existing.id, { total_count: editValues.chickenCount });
} else {
await api.countings.create({
cycle: selectedCycle.id,
date: record.date,
total_count: editValues.chickenCount,
});
}
}
setEditingDay(null);
setValidationErrors({});
await loadData(selectedCycle.id);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const handleCancel = () => {
setEditingDay(null);
setValidationErrors({});
};
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
return (
<div>
{/* Header */}
<PageHeader
title="Hitung Ayam"
icon={<i className="fa-solid fa-calculator" />}
subtitle={`Kelola dan edit data deplesi harian untuk siklus produksi aktif · ${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
/>
{/* 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>
)}
{/* 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>
{/* Read-only banner for closed cycles */}
{isReadOnly && (
<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>
<p className="text-xs text-amber-700 mt-1">
Untuk mengedit data, pilih siklus yang masih aktif pada dropdown di atas.
</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 isEditing = !isReadOnly && editingDay === record.day;
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 ${
isEditing ? 'bg-blue-50' : '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>
{!isReadOnly && (
<div className="flex-shrink-0">
{isEditing ? (
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={saving}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-green-500 text-white shadow-sm transition-colors hover:bg-green-600 disabled:opacity-60"
aria-label="Simpan"
>
<i className="fa-solid fa-check"></i>
</button>
<button
onClick={handleCancel}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-gray-500 text-white shadow-sm transition-colors hover:bg-gray-600"
aria-label="Batal"
>
<i className="fa-solid fa-times"></i>
</button>
</div>
) : (
<button
onClick={() => handleEdit(record)}
className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-blue-700"
>
<i className="fa-solid fa-edit"></i>
Edit
</button>
)}
</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>
{isEditing ? (
<div>
<input
type="number"
value={editValues.mati}
onChange={(e) =>
setEditValues({
...editValues,
mati: parseInt(e.target.value) || 0,
})
}
className="mt-1 w-full rounded-lg border border-red-200 bg-white px-2 py-2 text-right text-[11px] focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-200 md:px-3 md:text-sm"
/>
{validationErrors.mati && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.mati}
</div>
)}
</div>
) : (
<div className="text-[11px] font-semibold text-red-600 md:text-sm">
{formatNumber(record.mati, 0)}
</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>
{isEditing ? (
<div>
<input
type="number"
value={editValues.panen}
onChange={(e) =>
setEditValues({
...editValues,
panen: parseInt(e.target.value) || 0,
})
}
className="mt-1 w-full rounded-lg border border-purple-200 bg-white px-2 py-2 text-right text-[11px] focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200 md:px-3 md:text-sm"
/>
{validationErrors.panen && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.panen}
</div>
)}
</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>
{isEditing ? (
<div>
<input
type="number"
step="0.01"
min="0"
value={editValues.beratPanen}
onChange={(e) =>
setEditValues({
...editValues,
beratPanen: e.target.value,
})
}
className="mt-1 w-full rounded-lg border border-purple-200 bg-white px-2 py-2 text-right text-[11px] focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200 md:px-3 md:text-sm"
/>
{validationErrors.beratPanen && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.beratPanen}
</div>
)}
</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">
{isEditing
? Math.max(
0,
record.stockAwal - editValues.mati - editValues.panen
).toLocaleString('id-ID')
: 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>
{isEditing ? (
<div>
<input
type="number"
value={editValues.chickenCount || ''}
onChange={(e) =>
setEditValues({
...editValues,
chickenCount: e.target.value
? parseInt(e.target.value)
: undefined,
})
}
placeholder="0"
className="mt-1 w-full rounded-lg border border-blue-200 bg-white px-2 py-2 text-right text-[11px] focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-200 md:px-3 md:text-sm"
/>
{validationErrors.chickenCount && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.chickenCount}
</div>
)}
</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-[1080px] lg:min-w-[1180px] 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={2}
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 border-r border-gray-300"
>
Persentase
</th>
{!isReadOnly && (
<th
rowSpan={2}
className="w-24 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700"
>
Aksi
</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-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={isReadOnly ? 9 : 10}
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 isEditing = !isReadOnly && editingDay === record.day;
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 (calculated, read-only) */}
<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 (editable) */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{isEditing ? (
<div>
<input
type="number"
value={editValues.mati}
onChange={(e) =>
setEditValues({
...editValues,
mati: parseInt(e.target.value) || 0,
})
}
className="w-20 px-2 py-1 border border-blue-500 rounded text-right text-[11px] focus:ring-2 focus:ring-blue-500 md:w-24 md:text-sm"
/>
{validationErrors.mati && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.mati}
</div>
)}
</div>
) : (
<span className="text-[11px] font-semibold text-red-600 md:text-sm">
{formatNumber(record.mati, 0)}
</span>
)}
</td>
{/* JUMLAH PANEN (editable) */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{isEditing ? (
<div>
<input
type="number"
value={editValues.panen}
onChange={(e) =>
setEditValues({
...editValues,
panen: parseInt(e.target.value) || 0,
})
}
className="w-20 px-2 py-1 border border-blue-500 rounded text-center text-[11px] focus:ring-2 focus:ring-blue-500 md:w-24 md:text-sm"
/>
{validationErrors.panen && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.panen}
</div>
)}
</div>
) : (
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
{formatNumber(record.panen, 0)}
</span>
)}
</td>
{/* BERAT PANEN (editable) */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{isEditing ? (
<div>
<input
type="number"
step="0.01"
min="0"
value={editValues.beratPanen}
onChange={(e) =>
setEditValues({
...editValues,
beratPanen: e.target.value,
})
}
className="w-24 px-2 py-1 border border-blue-500 rounded text-center text-[11px] focus:ring-2 focus:ring-blue-500 md:w-28 md:text-sm"
/>
{validationErrors.beratPanen && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.beratPanen}
</div>
)}
</div>
) : (
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'}
</span>
)}
</td>
{/* Stock akhir (calculated, read-only) */}
<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">
{isEditing
? Math.max(
0,
record.stockAwal - editValues.mati - editValues.panen
).toLocaleString('id-ID')
: formatNumber(record.stockAkhir, 0)}
</td>
{/* Chicken Counting (editable) */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{isEditing ? (
<div>
<input
type="number"
value={editValues.chickenCount || ''}
onChange={(e) =>
setEditValues({
...editValues,
chickenCount: e.target.value
? parseInt(e.target.value)
: undefined,
})
}
placeholder="0"
className="w-24 px-2 py-1 border border-blue-500 rounded text-center text-[11px] focus:ring-2 focus:ring-blue-500 md:w-28 md:text-sm"
/>
{validationErrors.chickenCount && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.chickenCount}
</div>
)}
</div>
) : (
<span className="text-[11px] font-semibold text-blue-600 md:text-sm">
{record.chickenCount !== undefined
? formatNumber(record.chickenCount, 0)
: '-'}
</span>
)}
</td>
{/* Persentase (calculated, badge) */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{getPersentaseBadge(record.persentase)}
</td>
{/* Aksi */}
{!isReadOnly && (
<td className="px-2 py-2 whitespace-nowrap text-center">
{isEditing ? (
<div className="flex justify-center gap-2">
<button
onClick={handleSave}
disabled={saving}
className="px-2 py-1 bg-green-500 text-[11px] font-medium text-white rounded-lg transition-colors hover:bg-green-600 disabled:opacity-60 md:px-3 md:text-sm"
>
<i className="fa-solid fa-check"></i>
</button>
<button
onClick={handleCancel}
className="px-2 py-1 bg-gray-500 text-[11px] font-medium text-white rounded-lg transition-colors hover:bg-gray-600 md:px-3 md:text-sm"
>
<i className="fa-solid fa-times"></i>
</button>
</div>
) : (
<button
onClick={() => handleEdit(record)}
className="px-2 py-1 bg-blue-500 text-[11px] font-medium text-white rounded-lg transition-colors hover:bg-blue-600 md:px-3 md:text-sm"
>
<i className="fa-solid fa-edit mr-1"></i>
Edit
</button>
)}
</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 | <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>Data yang diedit akan
tersimpan sebagai input manual dan ditandai dengan highlight kuning
</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;