1422 lines
60 KiB
TypeScript
1422 lines
60 KiB
TypeScript
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;
|