Files
dashboard/components/counting/DataManagementPage.tsx
T
2026-07-29 10:48:03 +07:00

1422 lines
60 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, { useState, useEffect, useMemo } from 'react';
import { useAppContext } from '../../context/AppContext.tsx';
import { apiClient } from '../../services/apiService.ts';
import { Kandang, MortalityRecord } from '../../types/counting.ts';
import ActivityLogPanel from '../common/ActivityLogPanel.tsx';
import SimplePopulationChart from './SimplePopulationChart.tsx';
import { PageAiInsight } from '../shared/PageAiInsight.tsx';
interface DataManagementPageProps {
selectedKandangId: number | null;
kandangs: Kandang[];
}
interface MortalitySourceRecord {
day: number;
mortalityCount?: number;
afkir?: number;
panen?: number;
beratPanen?: number;
keterangan?: string;
isEdited?: boolean;
chickenCount?: number;
population?: number;
}
const DataManagementPage: React.FC<DataManagementPageProps> = ({ selectedKandangId, kandangs }) => {
const { cycleData } = useAppContext();
const [records, setRecords] = useState<MortalityRecord[]>([]);
const [editingDay, setEditingDay] = useState<number | null>(null);
const [editValues, setEditValues] = useState({
mati: 0,
afkir: 0,
panen: 0,
beratPanen: '' as string,
chickenCount: undefined as number | undefined,
keterangan: '',
});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [searchDay, setSearchDay] = useState<string>('');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [activityLogRefresh, setActivityLogRefresh] = useState(0);
const [iotCurrentPopulation, setIotCurrentPopulation] = useState<number | null>(null);
// Note: kandangs and selectedKandangId now come from props (global header dropdown)
// Fetch latest IOT Chicken Counting data (for selected kandang or all kandangs)
useEffect(() => {
const fetchLatestChickenCount = async () => {
try {
// Calculate date range for fetching latest data
const today = new Date();
const startDate = new Date(cycleData.startDate);
startDate.setHours(0, 0, 0, 0);
today.setHours(23, 59, 59, 999);
const startDateStr = startDate.toISOString().split('T')[0];
const todayStr = today.toISOString().split('T')[0];
// Fetch chicken counting data for selected kandang or all kandangs
const chickenCounts = await apiClient.getChickenCounting(
startDateStr,
todayStr,
selectedKandangId || undefined // Filter by kandang if one is selected
);
if (chickenCounts && chickenCounts.length > 0) {
// Get the most recent chicken count
const sortedCounts = [...chickenCounts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
const latestCount = sortedCounts[0].totalCount;
setIotCurrentPopulation(latestCount);
} else {
// No IOT data available
setIotCurrentPopulation(null);
}
} catch (error) {
console.error('Failed to fetch latest chicken counting data:', error);
setIotCurrentPopulation(null);
}
};
fetchLatestChickenCount();
// Refresh IOT data every 2 minutes
const interval = setInterval(fetchLatestChickenCount, 2 * 60 * 1000);
return () => clearInterval(interval);
}, [cycleData.startDate, selectedKandangId, kandangs]);
// Initial population based on selected kandang or total
const initialPopulation = useMemo(() => {
if (selectedKandangId === null) {
// "Semua Kandang" - sum all kandang doc_in_counts, fallback to cycle total
const total = kandangs.reduce((sum, k) => sum + (k.docInCount || 0), 0);
return total > 0 ? total : cycleData.docInCount || 20000;
}
const kandang = kandangs.find((k) => k.id === selectedKandangId);
return kandang?.docInCount || 0;
}, [selectedKandangId, kandangs, cycleData]);
const isReadOnly = selectedKandangId === null;
// Calculate records with deplesi flow
const calculateRecords = React.useCallback(
(
rawRecords: MortalitySourceRecord[],
initialPop: number,
chickenCountMap: Record<number, number>,
maxDay: number,
actualCurrentDay: number
): MortalityRecord[] => {
let previousStockAkhir = 0;
let cumulativeMortality = 0;
const result: MortalityRecord[] = [];
for (let day = 0; day <= maxDay; day++) {
const dbRecord = rawRecords.find((r) => r.day === day);
const mati = dbRecord?.mortalityCount || 0;
const afkir = dbRecord?.afkir || 0;
const panen = dbRecord?.panen || 0;
const beratPanen = dbRecord?.beratPanen || 0;
const keterangan = dbRecord?.keterangan || '';
const isEdited = dbRecord?.isEdited || false;
// Day 0: DOC In arrives (stockAwal = initialPop)
// Day 1+: stockAwal = previous day's stockAkhir
const stockAwal = day === 0 ? initialPop : previousStockAkhir;
const stockAkhir = stockAwal - mati - afkir - panen;
const chickenCount =
chickenCountMap[day] !== undefined ? chickenCountMap[day] : dbRecord?.chickenCount;
const persentase =
chickenCount !== undefined && chickenCount !== null && stockAkhir > 0
? (Math.min(chickenCount, stockAkhir) / Math.max(chickenCount, stockAkhir)) * 100
: 0;
cumulativeMortality += mati + afkir;
const date = new Date(cycleData.startDate);
date.setDate(date.getDate() + day);
const isToday = day === actualCurrentDay;
const isFuture = day > actualCurrentDay;
result.push({
day,
date: date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
}),
stockAwal,
mati,
afkir,
panen,
beratPanen,
stockAkhir,
chickenCount,
persentase: Math.round(persentase * 10) / 10,
keterangan,
mortality: mati,
mortalityTotal: cumulativeMortality,
chickenLife: stockAkhir,
isEdited,
isToday,
isFuture,
});
previousStockAkhir = stockAkhir;
}
return result;
},
[cycleData.startDate]
);
// Load mortality data from database
useEffect(() => {
const loadData = async () => {
try {
let rawRecords: MortalitySourceRecord[] = [];
if (selectedKandangId !== null) {
// Single kandang: get per-kandang records
rawRecords = await apiClient.getMortalityRecords(cycleData.id, selectedKandangId);
} else {
// "Semua Kandang": use aggregated totals
rawRecords = await apiClient.getMortalityTotals(cycleData.id);
}
// Calculate actual current day based on today's date
const startDate = cycleData.startDate;
const today = new Date();
today.setHours(0, 0, 0, 0);
const startDateMs = new Date(startDate).setHours(0, 0, 0, 0);
const todayMs = today.getTime();
const diffMs = todayMs - startDateMs;
const actualCurrentDay = diffMs >= 0 ? Math.floor(diffMs / (1000 * 60 * 60 * 24)) : 0;
// Last inclusive 0-based day index matches Estimasi Panen (startDate + totalDays - 1)
const lastCycleDay = Math.max(0, (cycleData.totalDays || 1) - 1);
const maxDay = Math.min(cycleData.currentDay ?? 0, lastCycleDay);
// Fetch chicken counting data
const chickenCountMap: Record<number, number> = {};
try {
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + maxDay);
const startDateStr = new Date(startDate).toISOString().split('T')[0];
const endDateStr = endDate.toISOString().split('T')[0];
const chickenCounts = await apiClient.getChickenCounting(
startDateStr,
endDateStr,
selectedKandangId || undefined
);
chickenCounts.forEach((cc: { date: string; totalCount: number }) => {
// Parse date string in local timezone to avoid off-by-one errors
const [year, month, day] = cc.date.split('T')[0].split('-').map(Number);
const ccDate = new Date(year, month - 1, day);
ccDate.setHours(0, 0, 0, 0);
const dayNum = Math.floor((ccDate.getTime() - startDateMs) / (1000 * 60 * 60 * 24));
if (dayNum >= 0 && dayNum <= maxDay) {
chickenCountMap[dayNum] = (chickenCountMap[dayNum] || 0) + cc.totalCount;
}
});
} catch (err) {
console.warn('Failed to load chicken counting data:', err);
}
const calculatedRecords = calculateRecords(
rawRecords,
initialPopulation,
chickenCountMap,
maxDay,
actualCurrentDay
);
setRecords(calculatedRecords);
} catch (error) {
console.error('Failed to load mortality data:', error);
setRecords([]);
}
};
loadData();
}, [
cycleData.id,
cycleData.startDate,
cycleData.currentDay,
cycleData.totalDays,
selectedKandangId,
initialPopulation,
calculateRecords,
]);
const loadMortalityData = async () => {
try {
let rawRecords: Array<{
day: number;
mortalityCount?: number;
afkir?: number;
panen?: number;
beratPanen?: number;
keterangan?: string;
isEdited?: boolean;
chickenCount?: number;
}> = [];
if (selectedKandangId !== null) {
// Single kandang: get per-kandang records
rawRecords = await apiClient.getMortalityRecords(cycleData.id, selectedKandangId);
} else {
// "Semua Kandang": use aggregated totals
rawRecords = await apiClient.getMortalityTotals(cycleData.id);
}
// Calculate actual current day based on today's date
const startDate = cycleData.startDate;
const today = new Date();
today.setHours(0, 0, 0, 0);
const startDateMs = new Date(startDate).setHours(0, 0, 0, 0);
const todayMs = today.getTime();
const diffMs = todayMs - startDateMs;
const actualCurrentDay = diffMs >= 0 ? Math.floor(diffMs / (1000 * 60 * 60 * 24)) : 0;
// Last inclusive 0-based day index matches Estimasi Panen (startDate + totalDays - 1)
const lastCycleDay = Math.max(0, (cycleData.totalDays || 1) - 1);
const maxDay = Math.min(cycleData.currentDay ?? 0, lastCycleDay);
// Fetch chicken counting data
const chickenCountMap: Record<number, number> = {};
try {
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + maxDay);
const startDateStr = new Date(startDate).toISOString().split('T')[0];
const endDateStr = endDate.toISOString().split('T')[0];
const chickenCounts = await apiClient.getChickenCounting(
startDateStr,
endDateStr,
selectedKandangId || undefined
);
chickenCounts.forEach((cc: { date: string; totalCount: number }) => {
// Parse date string in local timezone to avoid off-by-one errors
const [year, month, day] = cc.date.split('T')[0].split('-').map(Number);
const ccDate = new Date(year, month - 1, day);
ccDate.setHours(0, 0, 0, 0);
const dayNum = Math.floor((ccDate.getTime() - startDateMs) / (1000 * 60 * 60 * 24));
if (dayNum >= 0 && dayNum <= maxDay) {
chickenCountMap[dayNum] = (chickenCountMap[dayNum] || 0) + cc.totalCount;
}
});
} catch (err) {
console.warn('Failed to load chicken counting data:', err);
}
const calculatedRecords = calculateRecords(
rawRecords,
initialPopulation,
chickenCountMap,
maxDay,
actualCurrentDay
);
setRecords(calculatedRecords);
} catch (error) {
console.error('Failed to load mortality data:', error);
setRecords([]);
}
};
// Edit handlers
const handleEdit = (record: MortalityRecord) => {
setEditingDay(record.day);
setEditValues({
mati: record.mati,
afkir: record.afkir,
panen: record.panen,
beratPanen:
record.beratPanen !== undefined && record.beratPanen !== null
? record.beratPanen.toString()
: '',
chickenCount: record.chickenCount,
keterangan: record.keterangan,
});
setValidationErrors({});
};
const validateEditValues = (record: MortalityRecord): 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.afkir < 0) {
errors.afkir = 'Afkir tidak boleh negatif';
} else if (editValues.afkir > record.stockAwal - editValues.mati) {
errors.afkir = `Afkir tidak boleh lebih dari sisa (${record.stockAwal - editValues.mati})`;
}
if (editValues.panen < 0) {
errors.panen = 'Panen tidak boleh negatif';
} else if (editValues.panen > record.stockAwal - editValues.mati - editValues.afkir) {
errors.panen = `Panen tidak boleh lebih dari sisa (${record.stockAwal - editValues.mati - editValues.afkir})`;
}
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';
}
if (editValues.keterangan.length > 200) {
errors.keterangan = 'Keterangan maksimal 200 karakter';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSave = async () => {
if (editingDay === null) return;
const record = records.find((r) => r.day === editingDay);
if (!record) return;
if (!validateEditValues(record)) {
return;
}
try {
await apiClient.updateMortalityRecord(
cycleData.id,
editingDay,
editValues.mati,
editValues.chickenCount,
selectedKandangId || undefined,
editValues.panen,
editValues.keterangan,
editValues.afkir,
editValues.beratPanen === '' ? 0 : parseFloat(editValues.beratPanen)
);
// Save chicken count to chicken_counting table if provided
if (editValues.chickenCount !== undefined && selectedKandangId !== null) {
const kandang = kandangs.find((k) => k.id === selectedKandangId);
if (kandang) {
const kandangSlug = kandang.name.toLowerCase().replace(/\s+/g, '-');
const date = new Date(cycleData.startDate);
date.setDate(date.getDate() + editingDay);
const dateStr = date.toISOString().split('T')[0];
await apiClient.submitChickenCounting(
dateStr,
kandangSlug,
editValues.chickenCount,
true
);
}
}
setEditingDay(null);
setValidationErrors({});
loadMortalityData();
// Trigger activity log refresh
setActivityLogRefresh((prev) => prev + 1);
} catch (error) {
console.error('Failed to save:', error);
alert('Gagal menyimpan data. Silakan coba lagi.');
}
};
const handleCancel = () => {
setEditingDay(null);
setValidationErrors({});
};
// Filtered and sorted records
const filteredRecords = useMemo(() => {
let filtered = records;
if (searchDay) {
const searchNum = parseInt(searchDay, 10);
if (!isNaN(searchNum)) {
filtered = filtered.filter((r) => r.day === searchNum);
}
}
const sorted = [...filtered].sort((a, b) => {
if (sortOrder === 'asc') {
return a.day - b.day;
} else {
return b.day - a.day;
}
});
return sorted;
}, [records, searchDay, sortOrder]);
// Statistics
const stats = useMemo(() => {
const latestRecord = records[records.length - 1];
const totalMortality = latestRecord?.mortalityTotal || 0;
const totalPanen = records.reduce((sum, r) => sum + r.panen, 0);
const totalBeratPanen = records.reduce((sum, r) => sum + (r.beratPanen || 0), 0);
const mortalityRate = ((totalMortality / initialPopulation) * 100).toFixed(2);
const lastDay = latestRecord?.day || 0;
// Use IOT Chicken Counting data for all views (Semua Kandang or individual kandang)
// Fall back to calculated population only when IOT data is unavailable
let currentPopulation: number;
if (iotCurrentPopulation !== null) {
// Use latest IOT chicken count detection
currentPopulation = iotCurrentPopulation;
} else {
// Calculate from mortality records when IOT data unavailable
currentPopulation = latestRecord?.stockAkhir || initialPopulation;
}
return {
totalMortality,
currentPopulation,
totalPanen,
totalBeratPanen,
mortalityRate,
lastDay,
};
}, [records, initialPopulation, iotCurrentPopulation]);
// Badge for persentase
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>
);
};
// Prepare chart data
const chartData = useMemo(() => {
return records.map((r) => {
const date = new Date(cycleData.startDate);
date.setDate(date.getDate() + r.day);
const formattedDate = date.toLocaleDateString('id-ID', { day: '2-digit', month: 'short' });
return {
date: formattedDate,
population: r.stockAkhir,
chickenCount: r.chickenCount || undefined,
};
});
}, [records, cycleData.startDate]);
return (
<main className="px-4 sm:px-6 lg:px-8 pb-8">
{/* Header */}
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-800">Hitung Ayam</h1>
<p className="mt-1 text-gray-600">
Kelola dan edit data deplesi harian untuk siklus produksi aktif
</p>
</div>
{/* AI Insight */}
<PageAiInsight
topic="hitung_ayam"
cacheKey={`dm-${cycleData.id}-${selectedKandangId ?? 'all'}-${cycleData.currentDay}`}
contextData={{
cycleId: cycleData.id,
currentDay: cycleData.currentDay,
totalDays: cycleData.totalDays,
kandangFilter: selectedKandangId ?? 'semua',
initialPopulation,
currentPopulation: stats.currentPopulation,
totalMortality: stats.totalMortality,
mortalityRate: stats.mortalityRate,
totalPanen: stats.totalPanen,
iotCurrentPopulation,
}}
/>
{/* Kandang selection now handled by global header dropdown */}
{/* Population Chart */}
<div className="mb-6">
<SimplePopulationChart
data={chartData}
title={
selectedKandangId === null
? 'Tren Populasi (Semua Kandang)'
: `Tren Populasi - ${kandangs.find((k) => k.id === selectedKandangId)?.name || ''}`
}
/>
</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">
{initialPopulation.toLocaleString()}
</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">
{stats.currentPopulation.toLocaleString()}
</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">
{stats.totalMortality.toLocaleString()}
</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">
{stats.totalPanen.toLocaleString()} ekor
</p>
<p className="text-sm text-purple-600 mt-1">{stats.totalBeratPanen.toFixed(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>
{/* 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 "Semua Kandang" */}
{isReadOnly && kandangs.length > 0 && (
<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 (Total Semua Kandang)
</p>
<p className="text-xs text-amber-700 mt-1">
Untuk mengedit data, pilih kandang tertentu di tab 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.date}</div>
</div>
{!isReadOnly && (
<div className="flex-shrink-0">
{isEditing ? (
<div className="flex gap-2">
<button
onClick={handleSave}
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"
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">
{record.stockAwal.toLocaleString()}
</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">
{record.mati.toLocaleString()}
</div>
)}
</div>
<div className="rounded-xl bg-orange-50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-orange-500">
AFKIR
</div>
{isEditing ? (
<div>
<input
type="number"
value={editValues.afkir}
onChange={(e) =>
setEditValues({
...editValues,
afkir: parseInt(e.target.value) || 0,
})
}
className="mt-1 w-full rounded-lg border border-orange-200 bg-white px-2 py-2 text-right text-[11px] focus:border-orange-400 focus:outline-none focus:ring-2 focus:ring-orange-200 md:px-3 md:text-sm"
/>
{validationErrors.afkir && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.afkir}
</div>
)}
</div>
) : (
<div className="text-[11px] font-semibold text-orange-600 md:text-sm">
{record.afkir.toLocaleString()}
</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">
{record.panen.toLocaleString()}
</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
</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 ? record.beratPanen.toFixed(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
? (
record.stockAwal -
editValues.mati -
editValues.afkir -
editValues.panen
).toLocaleString()
: record.stockAkhir.toLocaleString()}
</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?.toLocaleString() || '-'}
</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 className="w-56 rounded-xl bg-gray-50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">
Keterangan
</div>
{isEditing ? (
<div className="mt-2 min-w-[160px] md:min-w-[220px]">
<textarea
value={editValues.keterangan}
onChange={(e) =>
setEditValues({ ...editValues, keterangan: e.target.value })
}
rows={4}
maxLength={200}
placeholder="Catatan..."
className="w-full min-w-[160px] resize-y rounded-xl border border-gray-300 px-2 py-2 text-[11px] focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 md:min-w-[220px] md:text-sm"
/>
<div className="mt-1 text-[11px] text-gray-500">
{editValues.keterangan.length}/200
</div>
{validationErrors.keterangan && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.keterangan}
</div>
)}
</div>
) : (
<div
className="mt-2 max-w-[160px] truncate text-[11px] text-gray-700 md:max-w-[220px] md:text-sm"
title={record.keterangan || '-'}
>
{record.keterangan || '-'}
</div>
)}
</div>
</div>
</div>
</article>
);
})
)}
</div>
<div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-[1240px] lg:min-w-[1320px] xl:min-w-[1400px] 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={3}
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>
<th
rowSpan={2}
className="w-56 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 border-r border-gray-300"
>
Keterangan
</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-16 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-blue-50 border-r border-gray-300">
AFKIR
</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 ? 11 : 12}
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.date}
</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">
{record.stockAwal.toLocaleString()}
</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">
{record.mati}
</span>
)}
</td>
{/* AFKIR (editable) */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{isEditing ? (
<div>
<input
type="number"
value={editValues.afkir}
onChange={(e) =>
setEditValues({
...editValues,
afkir: 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.afkir && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.afkir}
</div>
)}
</div>
) : (
<span className="text-[11px] font-semibold text-orange-600 md:text-sm">
{record.afkir}
</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">
{record.panen}
</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 ? record.beratPanen.toFixed(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
? (
record.stockAwal -
editValues.mati -
editValues.afkir -
editValues.panen
).toLocaleString()
: record.stockAkhir.toLocaleString()}
</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?.toLocaleString() || '-'}
</span>
)}
</td>
{/* Persentase (calculated, badge) */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{getPersentaseBadge(record.persentase)}
</td>
{/* Keterangan (editable textarea) */}
<td className="w-56 px-2 py-2 text-center border-r border-gray-200">
{isEditing ? (
<div className="mt-2 min-w-[160px] md:min-w-[220px]">
<textarea
value={editValues.keterangan}
onChange={(e) =>
setEditValues({ ...editValues, keterangan: e.target.value })
}
rows={4}
maxLength={200}
placeholder="Catatan..."
className="w-full min-w-[160px] resize-y rounded-xl border border-gray-300 px-2 py-2 text-[11px] focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 md:min-w-[220px] md:text-sm"
/>
<div className="mt-1 text-[11px] text-gray-500">
{editValues.keterangan.length}/200
</div>
{validationErrors.keterangan && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.keterangan}
</div>
)}
</div>
) : (
<div
className="mt-2 max-w-[160px] truncate text-[11px] text-gray-700 md:max-w-[220px] md:text-sm"
title={record.keterangan || '-'}
>
{record.keterangan || '-'}
</div>
)}
</td>
{/* Aksi */}
{!isReadOnly && (
<td className="px-2 py-2 whitespace-nowrap text-center">
{isEditing ? (
<div className="flex justify-center gap-2">
<button
onClick={handleSave}
className="px-2 py-1 bg-green-500 text-[11px] font-medium text-white rounded-lg transition-colors hover:bg-green-600 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 - AFKIR - panen (otomatis
terhitung)
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>
<strong>Mati:</strong> Mortalitas harian | <strong>AFKIR:</strong> Ayam diafkir |{' '}
<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
</li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>Data yang diedit akan tersimpan
permanen 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>
{/* Activity Log */}
<ActivityLogPanel tableName="mortality_records" refreshTrigger={activityLogRefresh} />
</main>
);
};
export default DataManagementPage;