1101 lines
49 KiB
TypeScript
1101 lines
49 KiB
TypeScript
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;
|