first commit

This commit is contained in:
Alberto-Audrix committed 2026-08-26 13:46:05 +07:00
commit 367533c02b
171 files changed
+101175

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
import React, { useEffect, useMemo, useState } from 'react';
import { cobbStandardPerformance } from '../mockData/cobbStandard.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { formatNumber, formatRatio } from '../utils/format.ts';
const CobbStandardPage: React.FC = () => {
const { selectedCycle } = useFarm();
const [initialBw, setInitialBw] = useState<number>(selectedCycle?.chick_in_weight || 42);
const [capacity, setCapacity] = useState<number>(selectedCycle?.doc_in_count || 50000);
useEffect(() => {
if (selectedCycle?.chick_in_weight) setInitialBw(selectedCycle.chick_in_weight);
if (selectedCycle?.doc_in_count) setCapacity(selectedCycle.doc_in_count);
}, [selectedCycle?.chick_in_weight, selectedCycle?.doc_in_count]);
const calculatedData = useMemo(() => {
const results = [];
let lastBw = Number(initialBw);
let lastCumFeed = 0;
for (const standard of cobbStandardPerformance) {
const currentBw = lastBw * (1 + standard.dailyGainPercent);
const dailyGainG = currentBw - lastBw;
const cumFeedG = currentBw * standard.fcr;
const dailyFeedG = cumFeedG - lastCumFeed;
const feedKg = (dailyFeedG * capacity) / 1000;
const feedBag = feedKg / 50;
results.push({
day: standard.day,
bw: Math.round(currentBw),
dailyGainG: Math.round(dailyGainG),
dailyGainPercent: standard.dailyGainPercent,
dailyFeedG: Math.round(dailyFeedG),
cumFeedG: Math.round(cumFeedG),
fcr: standard.fcr,
feedKg: Math.round(feedKg),
feedBag: Math.round(feedBag),
});
lastBw = currentBw;
lastCumFeed = cumFeedG;
}
return results;
}, [initialBw, capacity]);
return (
<main className="pb-8">
<div className="flex items-center mb-6">
<i className="fa-solid fa-calculator text-3xl text-red-600 mr-4"></i>
<div>
<h1 className="text-3xl font-bold text-gray-800">Kalkulator Standar Performa</h1>
<p className="mt-1 text-gray-600">
Hitung proyeksi pertumbuhan dan kebutuhan pakan berdasarkan bobot awal dan kapasitas
kandang.
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="bg-white p-4 rounded-xl shadow-sm">
<label htmlFor="initialBw" className="block text-sm font-medium text-gray-700">
Bobot Awal DOC (g)
</label>
<input
type="number"
id="initialBw"
value={initialBw}
onChange={(e) => setInitialBw(Number(e.target.value))}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 text-lg font-semibold"
/>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm">
<label htmlFor="capacity" className="block text-sm font-medium text-gray-700">
Kapasitas Kandang (ekor)
</label>
<input
type="number"
id="capacity"
value={capacity}
onChange={(e) => setCapacity(Number(e.target.value))}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 text-lg font-semibold"
/>
</div>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm overflow-hidden">
<div className="overflow-auto max-h-[calc(100vh-380px)]">
<table className="min-w-full border-collapse text-sm">
<thead className="bg-gray-700 text-white sticky top-0 z-10">
<tr>
<th
rowSpan={2}
className="p-2 border border-gray-800 font-semibold sticky left-0 bg-gray-700 z-20"
>
Umur
</th>
<th rowSpan={2} className="p-2 border border-gray-800 font-semibold">
BW (g)
</th>
<th colSpan={2} className="p-2 border border-gray-800 font-semibold">
Daily Gain
</th>
<th colSpan={2} className="p-2 border border-gray-800 font-semibold">
Feed Consumption
</th>
<th rowSpan={2} className="p-2 border border-gray-800 font-semibold">
FCR
</th>
<th colSpan={2} className="p-2 border border-gray-800 font-semibold">
Kebutuhan Pakan
</th>
</tr>
<tr>
<th className="p-2 border border-gray-800 font-semibold">(g)</th>
<th className="p-2 border border-gray-800 font-semibold">(%)</th>
<th className="p-2 border border-gray-800 font-semibold">Daily (g)</th>
<th className="p-2 border border-gray-800 font-semibold">Cum (g)</th>
<th className="p-2 border border-gray-800 font-semibold">(Kg)</th>
<th className="p-2 border border-gray-800 font-semibold">(Bag)</th>
</tr>
</thead>
<tbody className="font-mono">
<tr className="bg-yellow-100 font-bold">
<td className="p-1 border border-gray-200 text-center bg-gray-700 text-white sticky left-0 z-10">
0
</td>
<td className="p-1 border border-gray-200 text-center text-gray-700">
{initialBw}
</td>
<td className="p-1 border border-gray-200 text-center" colSpan={7}></td>
</tr>
{calculatedData.map((row) => {
const isSeventhDay = row.day > 0 && row.day % 7 === 0;
const rowClass = `hover:bg-red-50 ${isSeventhDay ? 'font-bold bg-blue-100' : 'even:bg-gray-100/50'}`;
const cellBorderClass = isSeventhDay
? 'border-b-[3px] border-double border-blue-400'
: 'border-gray-200';
return (
<tr key={row.day} className={rowClass}>
<td
className={`p-1 border ${cellBorderClass} text-center bg-gray-700 text-white font-semibold sticky left-0 z-10`}
>
{row.day}
</td>
<td
className={`p-1 border ${cellBorderClass} text-center font-bold text-red-600`}
>
{formatNumber(row.bw)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatNumber(row.dailyGainG)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{(row.dailyGainPercent * 100).toFixed(0)}%
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatNumber(row.dailyFeedG)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatNumber(row.cumFeedG)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatRatio(row.fcr)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatNumber(row.feedKg)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatNumber(row.feedBag)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<div className="text-right mt-2 text-xs text-gray-500 font-semibold">
Standar Performa COBB (2018)
</div>
</main>
);
};
export default CobbStandardPage;
+173
View File
@@ -0,0 +1,173 @@
import React, { useEffect, useState } from 'react';
import { useFarm } from '../context/FarmContext.tsx';
import { formatDateId, formatDateRange } from '../utils/format.ts';
import type { Cycle } from '../types/api.ts';
type Phase = { name: string; startDay: number; endDay: number; color: string };
function phasesFor(totalDays: number): Phase[] {
const starterEnd = Math.min(10, totalDays);
const growerEnd = Math.min(24, totalDays);
const phases: Phase[] = [
{ name: 'Starter', startDay: 1, endDay: starterEnd, color: 'bg-red-400' },
];
if (growerEnd > starterEnd) {
phases.push({
name: 'Grower',
startDay: starterEnd + 1,
endDay: growerEnd,
color: 'bg-orange-400',
});
}
if (totalDays > growerEnd) {
phases.push({
name: 'Finisher',
startDay: growerEnd + 1,
endDay: totalDays,
color: 'bg-yellow-500',
});
}
return phases;
}
const statusConfig = {
active: { label: 'Active', bg: 'bg-red-100', text: 'text-red-800', icon: 'fa-running' },
closed: {
label: 'Completed',
bg: 'bg-green-100',
text: 'text-green-800',
icon: 'fa-check-circle',
},
};
const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => {
const phases = phasesFor(cycle.total_days);
const currentDay = Math.min(Math.max(cycle.current_day, 0), cycle.total_days);
const progress = cycle.total_days > 0 ? (currentDay / cycle.total_days) * 100 : 0;
return (
<div className="pt-4">
<div className="flex flex-col sm:flex-row justify-between sm:items-center mb-4 gap-4">
<p className="text-sm text-gray-500">
Siklus:{' '}
<span className="font-semibold">{formatDateRange(cycle.start_date, cycle.end_date)}</span>
</p>
<div className="text-sm sm:text-right">
<p className="font-semibold text-gray-700">Estimasi Panen</p>
<p className="text-red-600 font-bold">{formatDateId(cycle.end_date)}</p>
</div>
</div>
<div className="relative pt-8">
<div className="relative h-8 bg-gray-200 rounded-lg flex items-center shadow-inner overflow-hidden">
{phases.map((phase) => {
const width = ((phase.endDay - phase.startDay + 1) / cycle.total_days) * 100;
return (
<div
key={phase.name}
className={`h-full ${phase.color} flex items-center justify-center text-white text-sm font-bold`}
style={{ width: `${width}%` }}
title={`${phase.name} (Hari ${phase.startDay}-${phase.endDay})`}
>
<span className="hidden sm:inline opacity-90">{phase.name}</span>
</div>
);
})}
</div>
{cycle.status === 'active' && currentDay > 0 && (
<div
className="absolute top-0 transition-all duration-500 ease-out"
style={{ left: `calc(${progress}% - 2.5rem)` }}
>
<div className="relative w-24 text-center">
<div className="font-bold text-red-600 bg-red-100 px-3 py-1.5 rounded-md shadow-sm border border-red-200 text-base">
Hari ke-{currentDay}
</div>
<div className="absolute left-1/2 -translate-x-1/2 w-0 h-0 border-l-8 border-l-transparent border-r-8 border-r-transparent border-t-8 border-t-red-100" />
</div>
</div>
)}
<div className="flex justify-between items-center text-sm font-semibold text-gray-500 mt-2 px-1">
<div className="flex flex-col items-start">
<span>Hari 1</span>
<span className="text-xs font-normal text-gray-400">
{formatDateId(cycle.start_date)}
</span>
<span className="text-xs font-normal text-gray-400">
Berat DOC: {cycle.chick_in_weight} gram
</span>
<span className="text-xs font-normal text-gray-400">
DOC In: {cycle.doc_in_count.toLocaleString('id-ID')} ekor
</span>
</div>
<div className="flex flex-col items-end">
<span>Hari {cycle.total_days}</span>
<span className="text-xs font-normal text-gray-400">
{formatDateId(cycle.end_date)}
</span>
</div>
</div>
</div>
</div>
);
};
const CycleTimeline: React.FC = () => {
const { cycles, selectedCycleId, setSelectedCycleId } = useFarm();
const [localId, setLocalId] = useState<number | null>(selectedCycleId);
useEffect(() => {
setLocalId(selectedCycleId);
}, [selectedCycleId]);
const selected = cycles.find((c) => c.id === localId) ?? null;
return (
<div className="bg-white p-6 sm:p-8 rounded-xl shadow-sm mb-8">
<h2 className="text-2xl font-bold text-gray-800 mb-4">Linimasa Siklus Produksi</h2>
{cycles.length === 0 ? (
<p className="text-sm text-gray-500">
Belum ada siklus untuk kandang ini. Buat di Pengaturan.
</p>
) : (
<>
<div className="border-b border-gray-200 overflow-x-auto">
<nav className="-mb-px flex space-x-6 min-w-max" aria-label="Tabs">
{cycles.map((cycle) => {
const config = statusConfig[cycle.status];
const isActive = cycle.id === selected?.id;
return (
<button
key={cycle.id}
onClick={() => {
setLocalId(cycle.id);
setSelectedCycleId(cycle.id);
}}
className={`flex items-center gap-2 px-3 py-2 text-sm font-semibold rounded-t-lg transition-colors border-b-2 whitespace-nowrap ${
isActive
? 'border-red-500 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<i className={`fa-solid ${config.icon}`} />
<span>
{formatDateId(cycle.start_date)} ({config.label})
</span>
</button>
);
})}
</nav>
</div>
{selected ? (
<SingleCycleView cycle={selected} />
) : (
<p className="text-sm text-gray-500 mt-4">
Pilih tab siklus di atas, atau buat di Pengaturan.
</p>
)}
</>
)}
</div>
);
};
export default CycleTimeline;
+331
View File
@@ -0,0 +1,331 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
import type {
AIInsight,
ChickenCounting,
ChickenWeight,
Karung,
KPI,
LatestAvg,
} from '../types/api.ts';
import type { View } from '../types/common.ts';
import { useFarm } from '../context/FarmContext.tsx';
import KpiCard, { type KpiCardModel } from './KpiCard.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
import { formatNumber, formatDateRange } from '../utils/format.ts';
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx';
import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx';
import type { DashboardReportType } from '../types/dashboardReport.ts';
type Props = { setView: (view: View) => void };
function chronological<T>(rows: T[]): T[] {
return [...rows].reverse();
}
const Dashboard: React.FC<Props> = ({ setView }) => {
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [kpis, setKpis] = useState<KPI[]>([]);
const [countings, setCountings] = useState<ChickenCounting[]>([]);
const [weights, setWeights] = useState<ChickenWeight[]>([]);
const [karung, setKarung] = useState<Karung | null>(null);
const [latestAvg, setLatestAvg] = useState<LatestAvg | null>(null);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [reportOpen, setReportOpen] = useState(false);
const [reportType, setReportType] = useState<DashboardReportType>('daily');
useEffect(() => {
if (!selectedCycle) {
setKpis([]);
setCountings([]);
setWeights([]);
setKarung(null);
setLatestAvg(null);
setInsights([]);
return;
}
const cycleId = selectedCycle.id;
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const [kpiRows, countingRows, weightRows, insightRows] = await Promise.all([
api.kpis.list({ cycle_id: cycleId }),
api.countings.list({ cycle_id: cycleId }),
api.weights.list({ cycle_id: cycleId }),
api.insights.list({ cycle_id: cycleId }),
]);
let latestKarung: Karung | null = null;
try {
latestKarung = await api.karungs.latest({ cycle_id: cycleId });
} catch (err) {
if (!(err instanceof ApiError && err.status === 404)) throw err;
}
let avg: LatestAvg | null = null;
try {
avg = await api.weights.latestAvg({ cycle_id: cycleId });
} catch (err) {
if (!(err instanceof ApiError && err.status === 404)) throw err;
}
if (cancelled) return;
setKpis(kpiRows);
setCountings(countingRows);
setWeights(weightRows);
setKarung(latestKarung);
setLatestAvg(avg);
setInsights(insightRows);
} catch (err) {
if (!cancelled) setError(errorMessage(err));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedCycle]);
const cards: KpiCardModel[] = useMemo(() => {
const latest = kpis[0];
const fcrTrend = chronological(kpis).map((row) => row.fcr);
const eefTrend = chronological(kpis).map((row) => row.eef);
const popTrend = chronological(countings).map((row) => row.total_count);
const weightTrend = chronological(weights).map((row) => row.average_weight);
const age = latest?.age ?? selectedCycle?.current_day ?? null;
return [
{
key: 'umur',
title: 'Umur',
value: age,
unit: 'hari',
icon: '📅',
status: age == null ? 'na' : 'ok',
},
{
key: 'populasi',
title: 'Populasi',
value: latest?.chicken_life ?? countings[0]?.total_count ?? null,
unit: 'ekor',
icon: '🐔',
trend: popTrend,
status: 'ok',
targetView: 'dataManagement',
},
{
key: 'karung',
title: 'Karung Dituang',
value: karung?.out_today ?? karung?.feed_use_today ?? null,
unit: 'karung',
icon: '🛢️',
status: karung ? 'ok' : 'na',
targetView: 'feedSackCounting',
},
{
key: 'weight',
title: 'Berat AVG',
value: latestAvg?.average_weight ?? weights[0]?.average_weight ?? null,
unit: 'gram',
icon: '⚖️',
trend: weightTrend,
status: latestAvg || weights[0] ? 'ok' : 'na',
targetView: 'weightMonitoring',
},
{
key: 'fcr',
title: 'FCR',
value: latest?.fcr ?? null,
unit: '',
icon: '📈',
trend: fcrTrend,
status: latest ? 'ok' : 'na',
targetView: 'fcrDashboard',
},
{
key: 'eef',
title: 'EEF',
value: latest?.eef ?? null,
unit: '',
icon: '🎯',
trend: eefTrend,
status: latest ? 'ok' : 'na',
targetView: 'eefDashboard',
},
];
}, [kpis, countings, weights, karung, latestAvg, selectedCycle]);
if (!selectedCycle) return <EmptyFarm onSettings={() => setView('kandangSettings')} />;
const feedTotal = kpis[0]?.feed_total ?? 0;
const feedWeightKg = feedTotal * 50;
const feedInTotal = karung?.in_total ?? 0;
const feedOutTotal = karung?.out_total ?? 0;
const feedInitialBalance = selectedCycle?.feed_initial_balance ?? 0;
const feedSaldo = feedInTotal - feedTotal - feedOutTotal;
const feedSaldoColor =
feedSaldo > 0 ? 'text-green-600' : feedSaldo < 0 ? 'text-red-600' : 'text-gray-600';
return (
<div>
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-2">
<PageHeader
title="Dashboard"
subtitle={`${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)} (${selectedCycle.status === 'active' ? 'aktif' : 'selesai'})`}
/>
<DashboardReportCtas
cycleClosed={selectedCycle.status === 'closed'}
hasDailySnapshot={false}
hasEndCycleSnapshot={false}
onOpenDaily={() => {
setReportType('daily');
setReportOpen(true);
}}
onOpenEndCycle={() => {
setReportType('end_cycle');
setReportOpen(true);
}}
/>
</div>
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
</div>
)}
{loading ? (
<Spinner />
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6 gap-6">
{cards.map((card) => (
<KpiCard
key={card.key}
title={card.title}
value={card.value}
unit={card.unit}
icon={card.icon}
trend={card.trend}
status={card.status}
onClick={card.targetView ? () => setView(card.targetView as View) : undefined}
/>
))}
</div>
<div className="mt-6 rounded-xl border-2 border-blue-200 bg-gradient-to-br from-blue-50 to-indigo-50 p-6 shadow-sm">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-lg bg-blue-500 p-3 text-white">
<i className="fa-solid fa-boxes-stacked text-2xl" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-800">Total Pakan</h2>
<p className="text-sm text-gray-600">Kumulatif karung pakan sejak awal siklus</p>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-white p-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div className="text-center">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-boxes-stacked text-blue-600" />
<p className="text-sm font-semibold text-gray-700">Total Pakan</p>
</div>
<p className="text-3xl font-bold text-blue-600">{formatNumber(feedTotal)}</p>
<p className="mt-1 text-xs text-gray-400">karung</p>
</div>
<div className="text-center sm:border-l-2 sm:border-blue-200 sm:pl-6">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-arrow-down text-green-600" />
<p className="text-sm font-semibold text-gray-700">Total Feed In</p>
</div>
<p className="text-3xl font-bold text-green-600">{formatNumber(feedInTotal)}</p>
<p className="mt-1 text-xs text-gray-400">karung</p>
</div>
<div className="text-center lg:border-l-2 lg:border-blue-200 lg:pl-6">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-arrow-right text-red-600" />
<p className="text-sm font-semibold text-gray-700">Total Feed Out</p>
</div>
<p className="text-3xl font-bold text-red-600">{formatNumber(feedOutTotal)}</p>
<p className="mt-1 text-xs text-gray-400">karung</p>
</div>
<div className="text-center lg:border-l-2 lg:border-blue-200 lg:pl-6">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-box-archive text-blue-600" />
<p className="text-sm font-semibold text-gray-700">Saldo Awal</p>
</div>
<p className="text-3xl font-bold text-blue-600">
{formatNumber(feedInitialBalance)}
</p>
<p className="mt-1 text-xs text-gray-400">karung</p>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 border-t border-blue-200 pt-6 sm:grid-cols-2">
<div className="text-center">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-weight-scale text-blue-600" />
<p className="text-sm font-semibold text-gray-700">Total Berat Pakan</p>
</div>
<p className="text-3xl font-bold text-blue-600">{formatNumber(feedWeightKg)}</p>
<p className="mt-1 text-xs text-gray-400">kg</p>
</div>
<div className="text-center sm:border-l-2 sm:border-blue-200 sm:pl-6">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-scale-balanced text-blue-600" />
<p className="text-sm font-semibold text-gray-700">Saldo Karung Pakan</p>
</div>
<p className={`text-3xl font-bold ${feedSaldoColor}`}>
{formatNumber(feedSaldo)}
</p>
<p className="mt-1 text-xs text-gray-400">karung</p>
</div>
</div>
</div>
</div>
<section className="mt-8">
<h2 className="text-lg font-bold text-gray-800 mb-3">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>
<DashboardManualInputForm
cycleId={selectedCycle.id}
currentDay={selectedCycle.current_day}
readOnly={selectedCycle.status !== 'active'}
/>
</>
)}
<ReportPreviewOverlay
open={reportOpen}
reportType={reportType}
onClose={() => setReportOpen(false)}
/>
</div>
);
};
export default Dashboard;
+471
View File
@@ -0,0 +1,471 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { api, errorMessage } from '../services/apiClient.ts';
import type { AIInsight, KPI } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
import { formatNumber, formatRatio } from '../utils/format.ts';
type EefRow = {
day: number;
chickenLifePercentage: number | null;
averageHarvestBw: number | null;
averageHarvestDay: number | null;
fcr: number | null;
eef: number | null;
};
type TrendPoint = { day: number; eef: number | null };
type SupportPoint = {
day: number;
chickenLifePercentage: number | null;
averageHarvestBw: number | null;
averageHarvestDay: number | null;
fcr: number | null;
};
const formatThreeDecimal = (value: number | null): string =>
value == null ? 'N/A' : formatRatio(value, 3);
const formatNum = (value: number | null | undefined, maxDecimals = 0): string =>
value == null || !Number.isFinite(Number(value))
? 'N/A'
: formatNumber(Number(value), maxDecimals, 0, true);
const StatCard: React.FC<{ title: string; value: string; unit: string }> = ({
title,
value,
unit,
}) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<p className="text-sm font-semibold text-gray-500">{title}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-bold text-gray-800">{value}</span>
<span className="text-sm font-semibold text-gray-500">{unit}</span>
</div>
</div>
);
const EefTrendChart: React.FC<{ data: TrendPoint[] }> = ({ data }) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Grafik EEF</h2>
<div className="h-80">
{data.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Tidak ada data EEF tersedia.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
tickFormatter={(value) => formatNum(Number(value), 0)}
width={60}
domain={[0, 'dataMax + 20']}
label={{ value: 'EEF', angle: -90, position: 'insideLeft' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #d1d5db',
borderRadius: '8px',
}}
formatter={(value) => [formatThreeDecimal(Number(value)), 'EEF']}
labelFormatter={(label) => `Hari ${label}`}
/>
<Line
type="monotone"
dataKey="eef"
name="EEF"
stroke="#dc2626"
strokeWidth={2.5}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
);
const SupportingMetricChart: React.FC<{
title: string;
data: SupportPoint[];
dataKey: keyof Omit<SupportPoint, 'day'>;
unit: string;
stroke: string;
maximumFractionDigits?: number;
valueFormatter?: (value: number) => string;
}> = ({ title, data, dataKey, unit, stroke, maximumFractionDigits = 0, valueFormatter }) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="text-lg font-bold text-gray-800 mb-4">{title}</h3>
<div className="h-64">
{data.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Tidak ada data tersedia.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
tickFormatter={(value) =>
valueFormatter
? valueFormatter(Number(value))
: formatNum(Number(value), maximumFractionDigits)
}
width={70}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #d1d5db',
borderRadius: '8px',
}}
formatter={(value) => {
const formatted = valueFormatter
? valueFormatter(Number(value))
: formatNum(Number(value), maximumFractionDigits);
return [`${formatted}${unit ? ` ${unit}` : ''}`, title];
}}
labelFormatter={(label) => `Hari ${label}`}
/>
<Line
type="monotone"
dataKey={dataKey}
name={title}
stroke={stroke}
strokeWidth={2.5}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
);
const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle } = useFarm();
const [rows, setRows] = useState<KPI[]>([]);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const loadData = useCallback(async () => {
if (!selectedCycle) return;
setError(null);
try {
const [kpiRows, insightRows] = await Promise.all([
api.kpis.list({ cycle_id: selectedCycle.id }),
api.insights.list({ cycle_id: selectedCycle.id }),
]);
setRows(kpiRows);
setInsights(insightRows);
setLastUpdated(new Date());
} catch (err) {
setError(errorMessage(err));
}
}, [selectedCycle]);
useEffect(() => {
if (!selectedCycle) {
setRows([]);
setInsights([]);
setLastUpdated(null);
return;
}
let cancelled = false;
(async () => {
setLoading(true);
try {
await loadData();
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedCycle, loadData]);
const handleRefresh = async () => {
setRefreshing(true);
try {
await loadData();
} finally {
setRefreshing(false);
}
};
const eefRows = useMemo<EefRow[]>(
() =>
[...rows]
.sort((a, b) => a.age - b.age || a.date.localeCompare(b.date))
.map((row) => ({
day: row.age,
chickenLifePercentage: row.chicken_life_percentage,
averageHarvestBw: row.average_harvest_weight > 0 ? row.average_harvest_weight : null,
averageHarvestDay: row.average_harvest_day > 0 ? row.average_harvest_day : null,
fcr: row.fcr > 0 ? row.fcr : null,
eef: row.eef > 0 ? row.eef : null,
})),
[rows]
);
const trendData = useMemo<TrendPoint[]>(
() => eefRows.map((row) => ({ day: row.day, eef: row.eef })),
[eefRows]
);
const supportData = useMemo<SupportPoint[]>(
() =>
eefRows.map((row) => ({
day: row.day,
chickenLifePercentage: row.chickenLifePercentage,
averageHarvestBw: row.averageHarvestBw,
averageHarvestDay: row.averageHarvestDay,
fcr: row.fcr,
})),
[eefRows]
);
const getLatestValid = <K extends keyof EefRow>(key: K): EefRow[K] | null => {
for (let i = eefRows.length - 1; i >= 0; i--) {
const val = eefRows[i]?.[key];
if (val !== null && val !== undefined && val !== 0 && String(val) !== '') {
return val;
}
}
return null;
};
const latestEef = getLatestValid('eef');
const eefInsights = useMemo(() => {
const matched = insights.filter((item) => {
const text =
`${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase();
return (
text.includes('eef') ||
text.includes('fcr') ||
text.includes('hidup') ||
text.includes('live') ||
text.includes('panen') ||
text.includes('bw')
);
});
return (matched.length > 0 ? matched : insights).slice(0, 4);
}, [insights]);
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
return (
<main className="mx-auto max-w-[1240px] space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<PageHeader
title="EEF"
subtitle="Dashboard European Efficiency Factor berdasarkan data performa produksi IoT."
icon={<i className="fa-solid fa-gauge-high" />}
/>
<button
onClick={handleRefresh}
disabled={refreshing || loading}
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
<i className={`fa-solid fa-rotate-right ${refreshing || loading ? 'fa-spin' : ''}`} />
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
</button>
</div>
{error && (
<div>
<Banner kind="error">{error}</Banner>
</div>
)}
<section>
<h2 className="text-lg font-bold text-gray-800 mb-3">
<i className="fa-solid fa-brain text-red-600 mr-2" />
AI Insight EEF
</h2>
{loading ? (
<Spinner />
) : eefInsights.length === 0 ? (
<Banner>Belum ada insight untuk siklus ini.</Banner>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{eefInsights.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>
{loading ? (
<Spinner />
) : (
<div className="space-y-8">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-6">
<StatCard title="EEF" value={formatThreeDecimal(latestEef)} unit="" />
<StatCard
title="Persentase Ayam Hidup"
value={formatNum(getLatestValid('chickenLifePercentage'), 3)}
unit="%"
/>
<StatCard
title="BW Rata-rata Panen"
value={formatNum(getLatestValid('averageHarvestBw'), 2)}
unit="g"
/>
<StatCard
title="Umur Rata-rata Panen"
value={formatNum(getLatestValid('averageHarvestDay'), 2)}
unit="hari"
/>
<StatCard title="FCR" value={formatThreeDecimal(getLatestValid('fcr'))} unit="" />
</div>
<EefTrendChart data={trendData} />
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-6 py-4 border-b border-gray-100">
<h2 className="text-xl font-bold text-gray-800">Data Pendukung EEF</h2>
{lastUpdated && (
<p className="text-sm text-gray-500">
Update:{' '}
{lastUpdated.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}
</p>
)}
</div>
<div className="p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Grafik Data Pendukung</h2>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<SupportingMetricChart
title="Persentase Ayam Hidup"
data={supportData}
dataKey="chickenLifePercentage"
unit="%"
stroke="#2563eb"
maximumFractionDigits={3}
/>
<SupportingMetricChart
title="BW Rata-rata Panen"
data={supportData}
dataKey="averageHarvestBw"
unit="g"
stroke="#16a34a"
maximumFractionDigits={2}
/>
<SupportingMetricChart
title="Umur Rata-rata Panen"
data={supportData}
dataKey="averageHarvestDay"
unit=""
stroke="#9333ea"
maximumFractionDigits={2}
/>
<SupportingMetricChart
title="FCR"
data={supportData}
dataKey="fcr"
unit=""
stroke="#dc2626"
valueFormatter={(value) => formatThreeDecimal(value)}
/>
</div>
</div>
<div className="border-t border-gray-100">
<div className="px-6 py-4">
<h2 className="text-xl font-bold text-gray-800">Tabel Data Pendukung EEF</h2>
</div>
<div className="overflow-auto max-h-[420px] border-t border-gray-100">
<table className="min-w-full text-sm">
<thead className="bg-gray-700 text-white sticky top-0 z-10">
<tr>
<th className="p-3 text-left font-semibold">Umur</th>
<th className="p-3 text-right font-semibold">Persentase Ayam Hidup</th>
<th className="p-3 text-right font-semibold">BW Rata-rata Panen</th>
<th className="p-3 text-right font-semibold">Umur Rata-rata Panen</th>
<th className="p-3 text-right font-semibold">FCR</th>
<th className="p-3 text-right font-semibold">EEF</th>
</tr>
</thead>
<tbody>
{eefRows.map((row) => (
<tr key={row.day} className="even:bg-gray-50 hover:bg-red-50">
<td className="p-3 font-semibold text-gray-800">Hari {row.day}</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.chickenLifePercentage, 3)}
{row.chickenLifePercentage != null ? '%' : ''}
</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.averageHarvestBw, 2)}
{row.averageHarvestBw != null ? ' g' : ''}
</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.averageHarvestDay, 2)}
</td>
<td className="p-3 text-right font-mono text-gray-700">
{formatThreeDecimal(row.fcr)}
</td>
<td className="p-3 text-right font-mono text-gray-700">
{formatThreeDecimal(row.eef)}
</td>
</tr>
))}
{eefRows.length === 0 && (
<tr>
<td colSpan={6} className="p-8 text-center text-gray-500">
Tidak ada data EEF tersedia.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</main>
);
};
export default EefDashboardPage;
+473
View File
@@ -0,0 +1,473 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { api, errorMessage } from '../services/apiClient.ts';
import type { AIInsight, ChickenWeight, KPI } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
import { formatNumber, formatRatio } from '../utils/format.ts';
type FcrRow = {
day: number;
feedTotal: number;
iotWeight: number | null;
chickenLife: number;
harvestWeightKg: number;
fcr: number | null;
};
type TrendPoint = { day: number; fcr: number | null };
type SupportPoint = {
day: number;
feedTotal: number;
iotWeight: number | null;
chickenLife: number;
harvestWeightKg: number;
};
const formatFcr = (value: number | null): string => (value == null ? 'N/A' : formatRatio(value, 3));
const formatNum = (value: number | null | undefined, maxDecimals = 0): string =>
value == null || !Number.isFinite(Number(value))
? 'N/A'
: formatNumber(Number(value), maxDecimals, 0, true);
const StatCard: React.FC<{ title: string; value: string; unit: string }> = ({
title,
value,
unit,
}) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<p className="text-sm font-semibold text-gray-500">{title}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-bold text-gray-800">{value}</span>
<span className="text-sm font-semibold text-gray-500">{unit}</span>
</div>
</div>
);
const FcrTrendChart: React.FC<{ data: TrendPoint[] }> = ({ data }) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Grafik FCR</h2>
<div className="h-80">
{data.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Tidak ada data FCR tersedia.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: -10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
width={50}
domain={[0, 'dataMax + 0.2']}
label={{ value: 'FCR', angle: -90, position: 'insideLeft' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #d1d5db',
borderRadius: '8px',
}}
formatter={(value) => [formatFcr(Number(value)), 'FCR']}
labelFormatter={(label) => `Hari ${label}`}
/>
<Line
type="monotone"
dataKey="fcr"
name="FCR"
stroke="#16a34a"
strokeWidth={2.5}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
);
const SupportingMetricChart: React.FC<{
title: string;
data: SupportPoint[];
dataKey: keyof Omit<SupportPoint, 'day'>;
unit: string;
stroke: string;
maximumFractionDigits?: number;
}> = ({ title, data, dataKey, unit, stroke, maximumFractionDigits = 0 }) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="text-lg font-bold text-gray-800 mb-4">{title}</h3>
<div className="h-64">
{data.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Tidak ada data tersedia.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
tickFormatter={(value) => formatNum(Number(value), maximumFractionDigits)}
width={70}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #d1d5db',
borderRadius: '8px',
}}
formatter={(value) => [
`${formatNum(Number(value), maximumFractionDigits)} ${unit}`,
title,
]}
labelFormatter={(label) => `Hari ${label}`}
/>
<Line
type="monotone"
dataKey={dataKey}
name={title}
stroke={stroke}
strokeWidth={2.5}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
);
const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle } = useFarm();
const [rows, setRows] = useState<KPI[]>([]);
const [weightRows, setWeightRows] = useState<ChickenWeight[]>([]);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const loadData = useCallback(async () => {
if (!selectedCycle) return;
setError(null);
try {
const [kpiRows, insightRows, weightList] = await Promise.all([
api.kpis.list({ cycle_id: selectedCycle.id }),
api.insights.list({ cycle_id: selectedCycle.id }),
api.weights.list({ cycle_id: selectedCycle.id }),
]);
setRows(kpiRows);
setInsights(insightRows);
setWeightRows(weightList);
setLastUpdated(new Date());
} catch (err) {
setError(errorMessage(err));
}
}, [selectedCycle]);
useEffect(() => {
if (!selectedCycle) {
setRows([]);
setWeightRows([]);
setInsights([]);
setLastUpdated(null);
return;
}
let cancelled = false;
(async () => {
setLoading(true);
try {
await loadData();
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedCycle, loadData]);
const handleRefresh = async () => {
setRefreshing(true);
try {
await loadData();
} finally {
setRefreshing(false);
}
};
const weightByAge = useMemo(() => {
const map = new Map<number, number>();
for (const weight of weightRows) {
if (weight.average_weight > 0 && !map.has(weight.age)) {
map.set(weight.age, weight.average_weight);
}
}
return map;
}, [weightRows]);
const fcrRows = useMemo<FcrRow[]>(
() =>
[...rows]
.sort((a, b) => a.age - b.age || a.date.localeCompare(b.date))
.map((row) => ({
day: row.age,
feedTotal: row.feed_total,
iotWeight: weightByAge.get(row.age) ?? null,
chickenLife: row.chicken_life,
harvestWeightKg: row.harvest_weight_total / 1000,
fcr: row.fcr > 0 ? row.fcr : null,
})),
[rows, weightByAge]
);
const trendData = useMemo<TrendPoint[]>(
() => fcrRows.map((row) => ({ day: row.day, fcr: row.fcr })),
[fcrRows]
);
const supportData = useMemo<SupportPoint[]>(
() =>
fcrRows.map((row) => ({
day: row.day,
feedTotal: row.feedTotal,
iotWeight: row.iotWeight,
chickenLife: row.chickenLife,
harvestWeightKg: row.harvestWeightKg,
})),
[fcrRows]
);
const getLatestValid = <K extends keyof FcrRow>(key: K): FcrRow[K] | null => {
for (let i = fcrRows.length - 1; i >= 0; i--) {
const val = fcrRows[i]?.[key];
if (val !== null && val !== undefined && val !== 0 && String(val) !== '') {
return val;
}
}
return null;
};
const latestFcr = getLatestValid('fcr');
const latestIotWeight = getLatestValid('iotWeight');
const fcrInsights = useMemo(() => {
const matched = insights.filter((item) => {
const text =
`${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase();
return (
text.includes('fcr') ||
text.includes('pakan') ||
text.includes('feed') ||
text.includes('konversi') ||
text.includes('panen')
);
});
return (matched.length > 0 ? matched : insights).slice(0, 4);
}, [insights]);
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
return (
<main className="mx-auto max-w-[1240px] space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<PageHeader
title="FCR"
subtitle="Dashboard Feed Conversion Ratio berdasarkan data pakan, bobot IoT, ayam hidup, dan panen."
icon={<i className="fa-solid fa-chart-line" />}
/>
<button
onClick={handleRefresh}
disabled={refreshing || loading}
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
<i className={`fa-solid fa-rotate-right ${refreshing || loading ? 'fa-spin' : ''}`} />
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
</button>
</div>
{error && (
<div>
<Banner kind="error">{error}</Banner>
</div>
)}
<section>
<h2 className="text-lg font-bold text-gray-800 mb-3">
<i className="fa-solid fa-brain text-red-600 mr-2" />
AI Insight FCR
</h2>
{loading ? (
<Spinner />
) : fcrInsights.length === 0 ? (
<Banner>Belum ada insight untuk siklus ini.</Banner>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{fcrInsights.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>
{loading ? (
<Spinner />
) : (
<div className="space-y-8">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-6">
<StatCard title="FCR" value={formatFcr(latestFcr)} unit="" />
<StatCard
title="Total Pakan"
value={formatNum(getLatestValid('feedTotal') ?? 0)}
unit="Karung"
/>
<StatCard title="Bobot IoT" value={formatNum(latestIotWeight, 2)} unit="g" />
<StatCard
title="Ayam Hidup"
value={formatNum(getLatestValid('chickenLife') ?? 0)}
unit="Ekor"
/>
<StatCard
title="Total Bobot Panen"
value={formatNum(getLatestValid('harvestWeightKg') ?? 0, 1)}
unit="kg"
/>
</div>
<FcrTrendChart data={trendData} />
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-6 py-4 border-b border-gray-100">
<h2 className="text-xl font-bold text-gray-800">Data Pendukung FCR</h2>
{lastUpdated && (
<p className="text-sm text-gray-500">
Update:{' '}
{lastUpdated.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}
</p>
)}
</div>
<div className="p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Grafik Data Pendukung</h2>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<SupportingMetricChart
title="Total Pakan"
data={supportData}
dataKey="feedTotal"
unit="karung"
stroke="#dc2626"
/>
<SupportingMetricChart
title="Bobot IoT"
data={supportData}
dataKey="iotWeight"
unit="g"
stroke="#16a34a"
maximumFractionDigits={2}
/>
<SupportingMetricChart
title="Ayam Hidup"
data={supportData}
dataKey="chickenLife"
unit="ekor"
stroke="#2563eb"
/>
<SupportingMetricChart
title="Total Bobot Panen"
data={supportData}
dataKey="harvestWeightKg"
unit="kg"
stroke="#9333ea"
maximumFractionDigits={1}
/>
</div>
</div>
<div className="border-t border-gray-100">
<div className="px-6 py-4">
<h2 className="text-xl font-bold text-gray-800">Tabel Data Pendukung FCR</h2>
</div>
<div className="overflow-auto max-h-[420px] border-t border-gray-100">
<table className="min-w-full text-sm">
<thead className="bg-gray-700 text-white sticky top-0 z-10">
<tr>
<th className="p-3 text-left font-semibold">Umur</th>
<th className="p-3 text-right font-semibold">Total Pakan</th>
<th className="p-3 text-right font-semibold">Bobot IoT</th>
<th className="p-3 text-right font-semibold">Ayam Hidup</th>
<th className="p-3 text-right font-semibold">Total Bobot Panen</th>
<th className="p-3 text-right font-semibold">FCR</th>
</tr>
</thead>
<tbody>
{fcrRows.map((row) => (
<tr key={row.day} className="even:bg-gray-50 hover:bg-red-50">
<td className="p-3 font-semibold text-gray-800">Hari {row.day}</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.feedTotal)} karung
</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.iotWeight, 2)}
{row.iotWeight != null ? ' g' : ''}
</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.chickenLife)} ekor
</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.harvestWeightKg, 1)} kg
</td>
<td className="p-3 text-right font-mono text-gray-700">
{formatFcr(row.fcr)}
</td>
</tr>
))}
{fcrRows.length === 0 && (
<tr>
<td colSpan={6} className="p-8 text-center text-gray-500">
Tidak ada data FCR tersedia.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</main>
);
};
export default FcrDashboardPage;
+318
View File
@@ -0,0 +1,318 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { api, errorMessage } from '../services/apiClient.ts';
import type { AIInsight, Cycle, Karung, ManualInput } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { Banner, EmptyFarm, Spinner } from './ui/Feedback.tsx';
import { addDaysIso, cycleDayForDate, formatDateId, todayIso } from '../utils/format.ts';
import FeedStats from './feedSack/FeedStats.tsx';
import FeedSaldoCard from './feedSack/FeedSaldoCard.tsx';
import FeedTrendChart from './feedSack/FeedTrendChart.tsx';
import FeedDailySummaryTable from './feedSack/FeedDailySummaryTable.tsx';
import InitialBalanceCard from './feedSack/InitialBalanceCard.tsx';
const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle, selectedKandang, selectedSite, cycles, setSelectedCycleId } = useFarm();
const [rows, setRows] = useState<Karung[]>([]);
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [showCyclePicker, setShowCyclePicker] = useState(false);
const reload = useCallback(async (cycleId: number) => {
setLoading(true);
setError(null);
try {
const [karungRows, insightRows, manualList] = await Promise.all([
api.karungs.list({ cycle_id: cycleId }),
api.insights.list({ cycle_id: cycleId }),
api.manualInputs.list({ cycle_id: cycleId }),
]);
setRows(karungRows);
setInsights(insightRows);
setManualRows(manualList);
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!selectedCycle) {
setRows([]);
setInsights([]);
setManualRows([]);
return;
}
void reload(selectedCycle.id);
}, [selectedCycle, reload]);
const sortedRows = useMemo(() => [...rows].sort((a, b) => a.date.localeCompare(b.date)), [rows]);
const latest = sortedRows[sortedRows.length - 1] ?? null;
const sortedManualRows = useMemo(
() => [...manualRows].sort((a, b) => a.date.localeCompare(b.date)),
[manualRows]
);
const displayDateIso = latest?.date ?? todayIso();
const latestManual =
sortedManualRows.find((row) => row.date === displayDateIso) ??
sortedManualRows[sortedManualRows.length - 1] ??
null;
// Display date follows the latest snapshot; without any snapshot, fall back to today.
// Cycle day is capped so data never extends past the cycle's planned duration.
const displayDate = latest?.date ?? todayIso();
const currentDay = useMemo(() => {
if (!selectedCycle) return 0;
const rawDay = cycleDayForDate(selectedCycle.start_date, displayDate);
return Math.min(rawDay, Math.max(0, selectedCycle.total_days - 1));
}, [selectedCycle, displayDate]);
const handleSync = useCallback(async () => {
if (!selectedCycle) return;
setSyncing(true);
setError(null);
setNotice(null);
try {
await api.karungs.request({ cycle_id: selectedCycle.id, section: 'today' });
setNotice('Sinkron karung-web-admin berhasil.');
await reload(selectedCycle.id);
} catch (err) {
setError(errorMessage(err));
} finally {
setSyncing(false);
}
}, [selectedCycle, reload]);
const sortedCycles = useMemo(() => {
return [...cycles].sort(
(a, b) => new Date(b.start_date).getTime() - new Date(a.start_date).getTime()
);
}, [cycles]);
const formatCycleDateRange = (cycle: Cycle) => {
const start = formatDateId(cycle.start_date);
if (cycle.end_date) return `${start} - ${formatDateId(cycle.end_date)}`;
return `${start} - ${formatDateId(addDaysIso(cycle.start_date, cycle.total_days))}`;
};
const lastUpdated = useMemo(() => {
if (!latest) return null;
const timestamp = latest.updated_at || latest.created_at;
if (!timestamp) return null;
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return null;
return `${date.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
})} ${date.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}`;
}, [latest]);
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
return (
<div>
{/* Page Title */}
<div className="flex items-center mb-6">
<div className="bg-red-600 text-white p-3 rounded-lg mr-4">
<i className="fa-solid fa-boxes-stacked text-2xl sm:text-3xl"></i>
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-800">Hitung Karung</h1>
<p className="mt-1 text-gray-600">Monitoring karung masuk dan karung dituang per hari</p>
{lastUpdated && (
<p className="text-xs text-gray-500 mt-1">
<i className="fa-solid fa-clock mr-1"></i>
Terakhir diperbarui: {lastUpdated}
</p>
)}
</div>
<div className="ml-auto flex items-center gap-2">
<button
onClick={handleSync}
disabled={syncing || selectedCycle.status !== 'active'}
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-60 hover:bg-red-700 transition-colors"
>
{syncing ? (
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
Sync...
</>
) : (
<>
<i className="fa-solid fa-arrows-rotate mr-2"></i>
Sync hari ini
</>
)}
</button>
</div>
</div>
{/* Cycle Selector */}
<div className="mb-6">
<div className="relative inline-block">
<button
onClick={() => setShowCyclePicker(!showCyclePicker)}
className="flex items-center gap-3 px-4 py-2 bg-white border border-gray-300 rounded-lg shadow-sm hover:bg-gray-50 transition-colors min-w-[340px] sm:min-w-[400px]"
>
<i className="fa-solid fa-rotate text-blue-600"></i>
<div className="flex-1 text-left">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800">
Siklus {formatCycleDateRange(selectedCycle)}
</span>
{selectedCycle.status === 'active' && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded">
Aktif
</span>
)}
{selectedCycle.status === 'closed' && (
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded">
Selesai
</span>
)}
</div>
</div>
<i
className={`fa-solid fa-chevron-down text-gray-400 text-xs transition-transform ${showCyclePicker ? 'rotate-180' : ''}`}
></i>
</button>
{/* Cycle Picker Dropdown */}
{showCyclePicker && (
<div className="absolute top-full left-0 mt-2 bg-white border border-gray-200 rounded-lg shadow-lg z-10 min-w-[340px] sm:min-w-[400px] max-h-[400px] overflow-y-auto">
{sortedCycles.map((cycle) => (
<button
key={cycle.id}
onClick={() => {
setSelectedCycleId(cycle.id);
setShowCyclePicker(false);
}}
className={`w-full px-4 py-3 text-left hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0 ${
cycle.id === selectedCycle.id ? 'bg-blue-50' : ''
}`}
>
<div className="flex items-center gap-2 mb-1">
{cycle.status === 'active' && (
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
)}
<span className="text-sm font-semibold text-gray-800">
Siklus {formatCycleDateRange(cycle)}
</span>
{cycle.status === 'active' && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded">
Aktif
</span>
)}
{cycle.status === 'closed' && (
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded">
Selesai
</span>
)}
</div>
</button>
))}
</div>
)}
</div>
</div>
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
</div>
)}
{notice && (
<div className="mb-4">
<Banner kind="success">{notice}</Banner>
</div>
)}
{loading ? (
<Spinner />
) : (
<div className="space-y-8">
{/* AI Insight */}
<section>
<h2 className="text-lg font-bold text-gray-800 mb-3">
<i className="fa-solid fa-brain text-red-600 mr-2"></i>
AI Insight Hitung Karung
</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>
{/* Saldo Awal */}
<InitialBalanceCard cycle={selectedCycle} onSaved={() => reload(selectedCycle.id)} />
{/* Ringkasan stats */}
<FeedStats
latest={latest}
latestManual={latestManual}
cycleStartDate={selectedCycle.start_date}
displayDate={displayDate}
/>
{/* Saldo Karung */}
<FeedSaldoCard
latest={latest}
cycleStartDate={selectedCycle.start_date}
displayDate={displayDate}
/>
{/* Tren harian */}
<FeedTrendChart
rows={sortedRows}
cycleStartDate={selectedCycle.start_date}
currentDay={currentDay}
/>
{/* Rincian harian */}
<FeedDailySummaryTable
rows={sortedRows}
manualRows={manualRows}
cycleStartDate={selectedCycle.start_date}
currentDay={currentDay}
/>
</div>
)}
<div className="mt-6 text-xs text-gray-400">
Sumber data: karung-web-admin · {selectedSite ? selectedSite.site_name : ''}
{selectedKandang ? ` · ${selectedKandang.kandang_name}` : ''}
</div>
</div>
);
};
export default FeedSackCountingPage;
+156
View File
@@ -0,0 +1,156 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAuth } from '../context/AuthContext.tsx';
import { useFarm } from '../context/FarmContext.tsx';
import { formatDateId } from '../utils/format.ts';
type HeaderProps = {
onToggleSidebar: () => void;
isTimelineVisible: boolean;
onToggleTimeline: () => void;
};
const Header: React.FC<HeaderProps> = ({
onToggleSidebar,
isTimelineVisible,
onToggleTimeline,
}) => {
const { user, logout } = useAuth();
const {
sites,
kandangs,
cycles,
selectedSiteId,
selectedKandangId,
selectedCycleId,
setSelectedSiteId,
setSelectedKandangId,
setSelectedCycleId,
} = useFarm();
const [open, setOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClick = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, []);
if (!user) return null;
return (
<header className="bg-white shadow-sm h-16 flex-shrink-0 z-10 border-b border-gray-200">
<div className="flex items-center justify-between h-16 px-4 sm:px-6 lg:px-8">
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={onToggleSidebar}
className="text-gray-500 hover:text-gray-800 focus:outline-none -ml-2"
aria-label="Toggle sidebar"
>
<i className="fa-solid fa-bars text-xl w-10 h-10 p-2 rounded-full hover:bg-gray-100" />
</button>
<div className="flex items-center gap-2">
<i className="fa-solid fa-map-marker-alt text-gray-500" />
<select
value={selectedSiteId ?? ''}
onChange={(e) => setSelectedSiteId(e.target.value ? Number(e.target.value) : null)}
className="px-3 py-1.5 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 bg-white text-sm text-gray-900"
>
{sites.length === 0 && <option value="">Tidak ada site</option>}
{sites.map((site) => (
<option key={site.id} value={site.id}>
{site.site_name}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<i className="fa-solid fa-warehouse text-gray-500" />
<select
value={selectedKandangId ?? ''}
onChange={(e) => setSelectedKandangId(e.target.value ? Number(e.target.value) : null)}
className="px-3 py-1.5 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 bg-white text-sm text-gray-900"
>
{kandangs.length === 0 && <option value="">Tidak ada kandang</option>}
{kandangs.map((kandang) => (
<option key={kandang.id} value={kandang.id}>
{kandang.kandang_name}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<i className="fa-solid fa-rotate text-gray-500" />
<select
value={selectedCycleId ?? ''}
onChange={(e) => setSelectedCycleId(e.target.value ? Number(e.target.value) : null)}
className="px-3 py-1.5 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 bg-white text-sm text-gray-900"
>
{cycles.length === 0 && <option value="">Tidak ada siklus</option>}
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.id}>
{formatDateId(cycle.start_date)} (
{cycle.status === 'active' ? 'Aktif' : 'Selesai'})
</option>
))}
</select>
</div>
</div>
<div className="flex items-center ml-auto">
<button
onClick={onToggleTimeline}
title="Toggle Linimasa"
className="hidden sm:flex items-center text-gray-500 hover:text-gray-800 focus:outline-none p-2 rounded-full hover:bg-gray-100"
>
<i
className={`fa-solid fa-calendar-week text-xl ${isTimelineVisible ? 'text-red-600' : 'text-gray-400'}`}
/>
</button>
<div className="relative ml-4" ref={dropdownRef}>
<button
onClick={() => setOpen(!open)}
className="flex items-center space-x-2 focus:outline-none"
>
<div className="w-10 h-10 rounded-full bg-red-100 text-red-700 flex items-center justify-center font-bold">
{user.user_name.slice(0, 1).toUpperCase()}
</div>
<div className="hidden lg:block text-left">
<p className="text-sm font-semibold text-gray-800">{user.user_name}</p>
<p className="text-xs text-gray-500">
{user.status === 'superadmin' ? 'Admin' : 'Pengguna'}
</p>
</div>
<i className="fa-solid fa-chevron-down text-xs text-gray-500" />
</button>
{open && (
<div className="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 z-50">
<div className="px-4 py-3 border-b">
<p className="text-sm font-semibold text-gray-800">{user.user_name}</p>
<p className="text-xs text-gray-500">
{user.status === 'superadmin' ? 'Admin' : 'Pengguna'}
</p>
</div>
<button
onClick={() => {
void logout();
setOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center"
>
<i className="fa-solid fa-sign-out-alt w-4 mr-2 text-gray-500" />
Keluar
</button>
</div>
)}
</div>
</div>
</div>
</header>
);
};
export default Header;
+595
View File
@@ -0,0 +1,595 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { api, errorMessage } from '../services/apiClient.ts';
import type { AIInsight, Flock, PanelIoT } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
import { cycleDayForDate, formatDateId, formatDateRange, formatNumber } from '../utils/format.ts';
import { computeExperienceCalc } from './iotPanel/chillFactor.ts';
import IotPanelChillFactorCard from './iotPanel/IotPanelChillFactorCard.tsx';
import IotPanelExperienceTempCard from './iotPanel/IotPanelExperienceTempCard.tsx';
const cardClass = 'rounded-xl border border-slate-200 bg-white p-4 shadow-sm';
const SECONDARY_CHARTS = [
{ key: 'humidity', label: 'Kelembapan', unit: ' %', color: '#3b82f6' },
{ key: 'wind', label: 'Angin', unit: ' m/s', color: '#6366f1' },
{ key: 'water', label: 'Air', unit: ' L', color: '#06b6d4' },
] as const;
const display = (value: number | null | undefined, suffix = '') =>
value == null || !Number.isFinite(Number(value))
? 'N/A'
: `${formatNumber(Number(value))}${suffix}`;
const formatTime = (iso: string | null | undefined): string => {
if (!iso) return '--';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
};
type DaySummary = {
count: number;
temp: number;
minTemp: number;
maxTemp: number;
experienceTemp: number;
minExp: number;
maxExp: number;
humidity: number;
windSpeed: number;
waterTotal: number;
};
const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
const [flocks, setFlocks] = useState<Flock[]>([]);
const [selectedFlockId, setSelectedFlockId] = useState<number | null>(null);
const [dates, setDates] = useState<string[]>([]);
const [selectedDate, setSelectedDate] = useState('');
const [dayRows, setDayRows] = useState<PanelIoT[]>([]);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshToken, setRefreshToken] = useState(0);
const [selectedChartKey, setSelectedChartKey] = useState<'humidity' | 'wind' | 'water' | null>(
null
);
useEffect(() => {
if (!selectedChartKey) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setSelectedChartKey(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selectedChartKey]);
useEffect(() => {
if (!selectedCycle) {
setFlocks([]);
setSelectedFlockId(null);
setDates([]);
setSelectedDate('');
setDayRows([]);
setInsights([]);
return;
}
let cancelled = false;
(async () => {
setError(null);
try {
const data = await api.flocks.list({ cycle_id: selectedCycle.id });
if (cancelled) return;
setFlocks(data);
setSelectedFlockId((previous) =>
previous && data.some((flock) => flock.id === previous) ? previous : (data[0]?.id ?? null)
);
} catch (err) {
if (!cancelled) setError(errorMessage(err));
}
})();
return () => {
cancelled = true;
};
}, [selectedCycle, refreshToken]);
useEffect(() => {
if (!selectedCycle || selectedFlockId == null) {
setDates([]);
setSelectedDate('');
setDayRows([]);
setInsights([]);
return;
}
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const [dateList, insightRows] = await Promise.all([
api.panelIot.dates({ cycle_id: selectedCycle.id, flock_id: selectedFlockId }),
api.insights.list({ cycle_id: selectedCycle.id }),
]);
if (cancelled) return;
const sortedDates = [...dateList].sort();
setDates(sortedDates);
setInsights(insightRows);
setSelectedDate((previous) =>
previous && sortedDates.includes(previous)
? previous
: (sortedDates[sortedDates.length - 1] ?? '')
);
} catch (err) {
if (!cancelled) setError(errorMessage(err));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedCycle, selectedFlockId, refreshToken]);
useEffect(() => {
if (!selectedCycle || selectedFlockId == null || !selectedDate) {
setDayRows([]);
return;
}
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const data = await api.panelIot.list({
cycle_id: selectedCycle.id,
flock_id: selectedFlockId,
date: selectedDate,
});
if (!cancelled) {
const sorted = [...data].sort((a, b) =>
(a.timestamp ?? a.date).localeCompare(b.timestamp ?? b.date)
);
setDayRows(sorted);
}
} catch (err) {
if (!cancelled) setError(errorMessage(err));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedCycle, selectedFlockId, selectedDate, refreshToken]);
const summary = useMemo<DaySummary | null>(() => {
if (dayRows.length === 0) return null;
const sorted = [...dayRows].sort((a, b) =>
(a.timestamp ?? a.date).localeCompare(b.timestamp ?? b.date)
);
const min = (key: keyof PanelIoT) => Math.min(...sorted.map((row) => Number(row[key])));
const max = (key: keyof PanelIoT) => Math.max(...sorted.map((row) => Number(row[key])));
const last = sorted[sorted.length - 1]!;
return {
count: sorted.length,
temp: last.average_temperature,
minTemp: min('average_temperature'),
maxTemp: max('average_temperature'),
experienceTemp: last.experience_temperature,
minExp: min('experience_temperature'),
maxExp: max('experience_temperature'),
humidity: last.humidity,
windSpeed: last.wind_speed,
waterTotal: last.water_total,
};
}, [dayRows]);
const chartRows = useMemo(
() =>
[...dayRows]
.sort((a, b) => (a.timestamp ?? a.date).localeCompare(b.timestamp ?? b.date))
.map((row) => ({
time: formatTime(row.timestamp),
average: row.average_temperature,
experience: row.experience_temperature,
humidity: row.humidity,
wind: row.wind_speed,
water: row.water_total,
})),
[dayRows]
);
const dayAge = selectedDate
? cycleDayForDate(selectedCycle?.start_date ?? '', selectedDate)
: null;
const experienceCalc = useMemo(
() =>
computeExperienceCalc({
averageTemp: summary?.temp,
windSpeed: summary?.windSpeed,
humidity: summary?.humidity,
dayAge,
}),
[summary, dayAge]
);
const iotInsights = useMemo(() => {
const matched = insights.filter((item) => {
const text =
`${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase();
return (
text.includes('iot') ||
text.includes('suhu') ||
text.includes('kelembapan') ||
text.includes('humidity') ||
text.includes('temperature') ||
text.includes('lingkungan')
);
});
return (matched.length > 0 ? matched : insights).slice(0, 4);
}, [insights]);
const refresh = () => {
setRefreshing(true);
setRefreshToken((token) => token + 1);
window.setTimeout(() => setRefreshing(false), 500);
};
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
return (
<main className="mx-auto max-w-[1240px] space-y-4">
<PageHeader
title="Panel IoT"
subtitle={`${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
icon={<i className="fa-solid fa-table-columns" />}
actions={
<div className="flex items-center gap-2">
<select
value={selectedFlockId ?? ''}
onChange={(event) =>
setSelectedFlockId(event.target.value ? Number(event.target.value) : null)
}
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700"
aria-label="Pilih flock"
>
{flocks.length === 0 && <option value="">Tidak ada flock</option>}
{flocks.map((flock) => (
<option key={flock.id} value={flock.id}>
{flock.flock_name}
</option>
))}
</select>
<button
type="button"
onClick={refresh}
disabled={refreshing}
className="rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100 disabled:opacity-60"
>
<i className={`fa-solid fa-rotate-right mr-1 ${refreshing ? 'fa-spin' : ''}`} />
Refresh
</button>
</div>
}
/>
{error && <Banner kind="error">{error}</Banner>}
{loading && dayRows.length === 0 ? (
<Spinner />
) : summary ? (
<>
<section>
<h2 className="mb-3 text-lg font-bold text-gray-800">
<i className="fa-solid fa-brain mr-2 text-red-600" />
AI Insight Panel IoT
</h2>
{iotInsights.length === 0 ? (
<Banner>Belum ada insight untuk siklus ini.</Banner>
) : (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{iotInsights.map((item) => (
<article
key={item.id}
className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm"
>
<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="mt-1 font-semibold text-gray-800">
{item.alert || 'Insight'}
</h3>
</div>
<span className="text-xs text-gray-400">{item.session}</span>
</div>
<p className="mt-3 whitespace-pre-wrap text-sm text-gray-600">
{item.insight_text}
</p>
</article>
))}
</div>
)}
</section>
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<span className="inline-flex items-center gap-2">
<i className="fa-regular fa-calendar" />
<label htmlFor="iot-date-picker" className="text-slate-500">
Tanggal:
</label>
<input
id="iot-date-picker"
type="date"
value={selectedDate}
min={dates[0] ?? undefined}
max={dates[dates.length - 1] ?? undefined}
onChange={(event) => setSelectedDate(event.target.value)}
className="rounded-lg border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-700 focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
</span>
<span className="text-xs">{summary.count} pembacaan</span>
</div>
<section className="grid gap-3 xl:grid-cols-[1.25fr_2.35fr]">
<div className="space-y-3">
<article className={cardClass}>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg bg-red-50/70 p-4">
<h2 className="text-sm font-semibold text-slate-700">
<i className="fa-solid fa-temperature-half mr-2 text-red-500" />
Suhu Rata-Rata
</h2>
<p className="mt-2 text-3xl font-bold text-slate-700">
{display(summary.temp, ' °C')}
</p>
<p className="mt-1 text-xs text-slate-500">
{display(summary.minTemp, ' °C')} – {display(summary.maxTemp, ' °C')}
</p>
</div>
<div className="rounded-lg bg-sky-50/80 p-4">
<h2 className="text-sm font-semibold text-slate-700">
<i className="fa-solid fa-temperature-half mr-2 text-sky-600" />
Experience Suhu
</h2>
<p className="mt-2 text-3xl font-bold text-sky-600">
{display(summary.experienceTemp, ' °C')}
</p>
<p className="mt-1 text-xs text-slate-500">
{display(summary.minExp, ' °C')} – {display(summary.maxExp, ' °C')}
</p>
</div>
</div>
</article>
<article className={cardClass}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{[
{
label: 'Kelembapan',
value: display(summary.humidity, ' %'),
icon: 'fa-droplet',
color: 'text-blue-500',
chartKey: 'humidity',
},
{
label: 'Angin',
value: display(summary.windSpeed, ' m/s'),
icon: 'fa-wind',
color: 'text-indigo-500',
chartKey: 'wind',
},
{
label: 'Air total',
value: display(summary.waterTotal, ' L'),
icon: 'fa-water',
color: 'text-cyan-500',
chartKey: 'water',
},
{
label: 'Umur',
value: dayAge == null ? 'N/A' : `${dayAge} hari`,
icon: 'fa-dove',
color: 'text-amber-600',
},
{
label: 'Tanggal',
value: formatDateId(selectedDate),
icon: 'fa-calendar-day',
color: 'text-slate-500',
},
{
label: 'Flock',
value:
flocks.find((flock) => flock.id === selectedFlockId)?.flock_name ?? 'N/A',
icon: 'fa-warehouse',
color: 'text-slate-500',
},
].map((tile) => {
const inner = (
<>
<p className="text-sm text-slate-500">
<i className={`fa-solid ${tile.icon} mr-2 ${tile.color}`} />
{tile.label}
</p>
<p className="mt-2 text-xl font-semibold text-slate-700">{tile.value}</p>
</>
);
if (!tile.chartKey) {
return (
<div
key={tile.label}
className="min-h-[104px] rounded-xl border border-slate-200 bg-white p-3"
>
{inner}
</div>
);
}
return (
<button
key={tile.label}
type="button"
onClick={() =>
setSelectedChartKey(tile.chartKey as 'humidity' | 'wind' | 'water')
}
className="min-h-[104px] cursor-pointer rounded-xl border border-slate-200 bg-white p-3 text-left transition hover:bg-slate-50"
aria-label={`Lihat grafik ${tile.label}`}
>
{inner}
</button>
);
})}
</div>
</article>
<IotPanelExperienceTempCard
calc={experienceCalc}
apiExperienceTemp={summary.experienceTemp}
/>
</div>
<div className="space-y-3">
<article className={`${cardClass} min-h-[390px]`}>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h2 className="font-semibold text-slate-800">Histori Suhu Harian</h2>
<p className="mt-1 text-xs text-slate-500">
<span className="inline-flex items-center gap-1 mr-3">
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
Garis merah = suhu rata-rata
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-full bg-sky-600" />
Garis biru = experience suhu
</span>
</p>
</div>
<span className="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700">
{formatDateId(selectedDate)}
</span>
</div>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartRows}
margin={{ top: 8, right: 12, left: -18, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} stroke="#64748b" />
<YAxis tick={{ fontSize: 11 }} stroke="#64748b" />
<Tooltip
formatter={(value, name) => [
`${formatNumber(Number(value))} °C`,
name as string,
]}
/>
<Line
type="monotone"
dataKey="average"
name="Suhu rata-rata"
stroke="#ef4444"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="experience"
name="Experience suhu"
stroke="#0284c7"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</article>
<IotPanelChillFactorCard dayAge={dayAge} chillFactor={experienceCalc.chillFactor} />
</div>
</section>
</>
) : (
<div className={cardClass}>
<p className="py-8 text-center text-slate-400">
Belum ada data panel IoT untuk flock ini.
</p>
</div>
)}
{selectedChartKey &&
(() => {
const chart = SECONDARY_CHARTS.find((item) => item.key === selectedChartKey)!;
const latestPoint = chartRows[chartRows.length - 1];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={() => setSelectedChartKey(null)}
>
<div
className="w-full max-w-2xl rounded-xl bg-white p-5 shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-slate-800">{chart.label}</h3>
<p className="text-xs text-slate-500">
Pembacaan sepanjang hari · {formatDateId(selectedDate)}
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-slate-50 px-2 py-1 text-xs font-semibold text-slate-600">
Terakhir: {display(latestPoint?.[chart.key], chart.unit)}
</span>
<button
type="button"
onClick={() => setSelectedChartKey(null)}
className="rounded-lg border border-slate-200 p-2 text-slate-600 hover:bg-slate-50"
aria-label="Tutup grafik"
>
<i className="fa-solid fa-xmark" />
</button>
</div>
</div>
<div className="h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartRows} margin={{ top: 4, right: 8, left: -14, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} stroke="#64748b" />
<YAxis tick={{ fontSize: 11 }} stroke="#64748b" width={38} />
<Tooltip
formatter={(value, name) => [
`${formatNumber(Number(value))}${chart.unit}`,
name as string,
]}
/>
<Line
type="monotone"
dataKey={chart.key}
name={chart.label}
stroke={chart.color}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
})()}
</main>
);
};
export default IotPanelPage;
+121
View File
@@ -0,0 +1,121 @@
import React, { useMemo } from 'react';
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
import { formatNumber, formatRatio } from '../utils/format.ts';
import type { View } from '../types/common.ts';
export type KpiStatus = 'ok' | 'warning' | 'critical' | 'na';
export type KpiCardModel = {
key: string;
title: string;
value: number | null;
unit: string;
icon: string;
trend?: number[];
status: KpiStatus;
targetView?: View;
};
const statusStyles: Record<KpiStatus, { bg: string; text: string; icon: string; line: string }> = {
ok: { bg: 'bg-green-100', text: 'text-green-800', icon: 'text-green-600', line: '#16a34a' },
warning: {
bg: 'bg-yellow-100',
text: 'text-yellow-800',
icon: 'text-yellow-600',
line: '#f59e0b',
},
critical: { bg: 'bg-red-100', text: 'text-red-800', icon: 'text-red-600', line: '#dc2626' },
na: { bg: 'bg-blue-50', text: 'text-blue-900', icon: 'text-blue-600', line: '#3b82f6' },
};
type Props = Omit<KpiCardModel, 'key' | 'targetView'> & {
onClick?: () => void;
isLoading?: boolean;
};
const KpiCard: React.FC<Props> = ({
title,
value,
unit,
icon,
trend,
status,
onClick,
isLoading = false,
}) => {
const isNA = value === null;
const styles = isNA ? statusStyles.na : statusStyles[status];
const trendData = useMemo(
() => (trend ?? []).map((point, index) => ({ name: index, value: point })),
[trend]
);
const displayValue = () => {
if (value === null) return 'N/A';
if (title.toUpperCase().includes('FCR') || title.toUpperCase().includes('EEF')) {
return formatRatio(value, 3);
}
if (unit === 'gram' && value > 999) return formatNumber(value / 1000, 2);
return formatNumber(value, 2);
};
const displayUnit = () => {
if (value !== null && unit === 'gram' && value > 999) return 'kg';
return unit;
};
return (
<div
className={`p-4 sm:p-5 rounded-xl shadow-sm transition-all duration-300 hover:shadow-lg ${styles.bg} ${
onClick ? 'cursor-pointer hover:scale-105' : ''
} flex flex-col h-full`}
onClick={onClick}
>
<div className="flex justify-between items-start mb-2">
<div className="flex flex-col flex-1 min-w-0">
<div className={`text-2xl sm:text-3xl font-bold ${styles.text} truncate`}>
{isLoading ? (
<span className="text-base sm:text-xl">
<i className="fa-solid fa-spinner fa-spin mr-2" />
Memuat...
</span>
) : (
<>
{displayValue()}{' '}
{!isNA && <span className="text-lg sm:text-xl font-medium">{displayUnit()}</span>}
</>
)}
</div>
<p className={`text-xs sm:text-sm font-semibold mt-1 ${styles.text}`}>{title}</p>
</div>
<div className={`text-2xl sm:text-3xl ${styles.icon} flex-shrink-0 ml-2`}>{icon}</div>
</div>
<div className="h-12 sm:h-14 lg:h-16 mt-auto">
{!isNA && trendData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trendData} margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
<Line
type="monotone"
dataKey="value"
stroke={styles.line}
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Tooltip
formatter={(val) => [formatNumber(Number(val ?? 0), 2), title]}
contentStyle={{ fontSize: 12, borderRadius: 8 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-full text-xs text-gray-400">
{isNA ? 'Data tidak tersedia' : ''}
</div>
)}
</div>
</div>
);
};
export default KpiCard;
+298
View File
@@ -0,0 +1,298 @@
import React, { useState } from 'react';
import { api, errorMessage } from '../services/apiClient.ts';
import type { CycleStatus } from '../types/api.ts';
import { useAuth } from '../context/AuthContext.tsx';
import { useFarm } from '../context/FarmContext.tsx';
import { Banner, PageHeader } from './ui/Feedback.tsx';
import { addDaysIso, formatDateId, inclusiveDayCount, todayIso } from '../utils/format.ts';
const inputClass =
'border border-gray-300 rounded-md px-3 py-2 text-sm w-full focus:ring-red-500 focus:border-red-500';
const SettingsPage: React.FC = () => {
const { user } = useAuth();
const {
sites,
kandangs,
cycles,
selectedSiteId,
selectedKandangId,
reload,
setSelectedSiteId,
setSelectedKandangId,
setSelectedCycleId,
} = useFarm();
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [siteName, setSiteName] = useState('');
const [kandangName, setKandangName] = useState('');
const [startDate, setStartDate] = useState(todayIso());
const [endDate, setEndDate] = useState(() => addDaysIso(todayIso(), 34));
const [docInCount, setDocInCount] = useState(20000);
const [chickInWeight, setChickInWeight] = useState(45);
const [cycleStatus, setCycleStatus] = useState<CycleStatus>('active');
const flash = (msg: string) => {
setNotice(msg);
setError(null);
};
const isSuperAdmin = user?.status === 'superadmin';
const wrap = async (fn: () => Promise<void>) => {
setSaving(true);
setError(null);
try {
await fn();
await reload();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
return (
<div>
<PageHeader title="Pengaturan" subtitle="Site, kandang, dan siklus produksi" />
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
</div>
)}
{notice && (
<div className="mb-4">
<Banner kind="success">{notice}</Banner>
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-3">Sites</h2>
{isSuperAdmin ? (
<form
className="flex gap-2 mb-4"
onSubmit={(e) => {
e.preventDefault();
if (!user) return;
void wrap(async () => {
const created = await api.sites.create({ site_name: siteName, user: user.id });
setSiteName('');
setSelectedSiteId(created.id);
flash(`Site ${created.site_name} dibuat.`);
});
}}
>
<input
className={inputClass}
placeholder="Nama site"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
required
/>
<button
className="bg-red-600 text-white px-3 rounded-lg text-sm font-semibold"
disabled={saving}
>
Tambah
</button>
</form>
) : (
<p className="mb-4 text-xs text-gray-500">Hanya admin yang dapat membuat site.</p>
)}
<ul className="divide-y">
{sites.map((site) => (
<li key={site.id} className="py-2 flex items-center justify-between gap-2">
<button
className={`text-left text-sm ${site.id === selectedSiteId ? 'font-semibold text-red-600' : 'text-gray-700'}`}
onClick={() => setSelectedSiteId(site.id)}
>
{site.site_name}
</button>
{isSuperAdmin && (
<button
className="text-xs text-red-500 hover:underline"
onClick={() =>
void wrap(async () => {
await api.sites.remove(site.id);
flash('Site dihapus.');
})
}
>
Hapus
</button>
)}
</li>
))}
</ul>
</section>
<section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-3">Kandang</h2>
{isSuperAdmin ? (
<form
className="space-y-2 mb-4"
onSubmit={(e) => {
e.preventDefault();
if (!selectedSiteId) return;
void wrap(async () => {
const created = await api.kandangs.create({
kandang_name: kandangName,
site: selectedSiteId,
});
setKandangName('');
setSelectedKandangId(created.id);
flash(`Kandang ${created.kandang_name} dibuat.`);
});
}}
>
<input
className={inputClass}
placeholder="Nama kandang"
value={kandangName}
onChange={(e) => setKandangName(e.target.value)}
required
/>
<button
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
disabled={saving}
>
Tambah kandang
</button>
</form>
) : (
<p className="mb-4 text-xs text-gray-500">Hanya admin yang dapat membuat kandang.</p>
)}
<ul className="divide-y">
{kandangs.map((kandang) => (
<li key={kandang.id} className="py-2 flex items-center justify-between gap-2">
<button
className={`text-left text-sm ${kandang.id === selectedKandangId ? 'font-semibold text-red-600' : 'text-gray-700'}`}
onClick={() => setSelectedKandangId(kandang.id)}
>
{kandang.kandang_name}
</button>
{isSuperAdmin && (
<button
className="text-xs text-red-500 hover:underline"
onClick={() =>
void wrap(async () => {
await api.kandangs.remove(kandang.id);
flash('Kandang dihapus.');
})
}
>
Hapus
</button>
)}
</li>
))}
</ul>
</section>
<section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-3">Siklus</h2>
<form
className="space-y-2 mb-4"
onSubmit={(e) => {
e.preventDefault();
if (!selectedKandangId) return;
void wrap(async () => {
await api.cycles.create({
kandang: selectedKandangId,
total_days: inclusiveDayCount(startDate, endDate),
current_day: 1,
start_date: startDate,
end_date: endDate,
chick_in_weight: chickInWeight,
doc_in_count: docInCount,
status: cycleStatus,
});
flash('Siklus dibuat.');
});
}}
>
<label className="block text-sm">
Mulai
<input
type="date"
className={`${inputClass} mt-1`}
value={startDate}
onChange={(e) => {
const nextStart = e.target.value;
setStartDate(nextStart);
if (endDate < nextStart) setEndDate(nextStart);
}}
/>
</label>
<label className="block text-sm">
Selesai
<input
type="date"
className={`${inputClass} mt-1`}
min={startDate}
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</label>
<label className="block text-sm">
DOC in (ekor)
<input
type="number"
className={`${inputClass} mt-1`}
value={docInCount}
onChange={(e) => setDocInCount(Number(e.target.value))}
/>
</label>
<label className="block text-sm">
Berat DOC (g)
<input
type="number"
className={`${inputClass} mt-1`}
value={chickInWeight}
onChange={(e) => setChickInWeight(Number(e.target.value))}
/>
</label>
<select
className={inputClass}
value={cycleStatus}
onChange={(e) => setCycleStatus(e.target.value as CycleStatus)}
>
<option value="active">active</option>
<option value="closed">closed</option>
</select>
<button
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
disabled={saving}
>
Tambah siklus
</button>
</form>
<ul className="divide-y">
{cycles.map((cycle) => (
<li key={cycle.id} className="py-2 text-sm text-gray-700">
<div className="flex justify-between gap-2">
<button className="text-left" onClick={() => setSelectedCycleId(cycle.id)}>
{formatDateId(cycle.start_date)} – {formatDateId(cycle.end_date)}
<span className="block text-xs text-gray-400">{cycle.status}</span>
</button>
<button
className="text-xs text-red-500 hover:underline"
onClick={() =>
void wrap(async () => {
await api.cycles.remove(cycle.id);
flash('Siklus dihapus.');
})
}
>
Hapus
</button>
</div>
</li>
))}
</ul>
</section>
</div>
</div>
);
};
export default SettingsPage;
+124
View File
@@ -0,0 +1,124 @@
import React from 'react';
import { View } from '../types/common.ts';
import NavLink, { NavItem } from './sidebar/NavLink.tsx';
const navItems: NavItem[] = [
{
view: 'dashboard',
label: 'Dashboard',
icon: <i className="fa-solid fa-home"></i>,
},
{
view: 'dataManagement',
label: 'Hitung Ayam',
icon: <i className="fa-solid fa-calculator"></i>,
},
{
view: 'weightMonitoring',
label: 'Berat Ayam',
icon: <i className="fa-solid fa-weight-scale"></i>,
subItems: [
{
view: 'weightMonitoring',
label: 'Dashboard Berat',
icon: <i className="fa-solid fa-chart-line"></i>,
},
{
view: 'weightStandard',
label: 'Standar Ross',
icon: <i className="fa-solid fa-table-list"></i>,
},
],
},
{
view: 'feedSackCounting',
label: 'Manajemen Pakan',
icon: <i className="fa-solid fa-boxes-stacked"></i>,
subItems: [
{
view: 'feedSackCounting',
label: 'Hitung Karung',
icon: <i className="fa-solid fa-boxes-stacked"></i>,
},
{
view: 'cobbStandard',
label: 'Standar Performa',
icon: <i className="fa-solid fa-table-list"></i>,
},
],
},
{
view: 'iotPanel',
label: 'Panel IoT',
icon: <i className="fa-solid fa-table-columns"></i>,
},
{
view: 'fcrDashboard',
label: 'FCR',
icon: <i className="fa-solid fa-chart-line"></i>,
},
{
view: 'eefDashboard',
label: 'EEF',
icon: <i className="fa-solid fa-gauge-high"></i>,
},
{
view: 'kandangSettings',
label: 'Pengaturan',
icon: <i className="fa-solid fa-gear"></i>,
},
];
interface SidebarProps {
isCollapsed: boolean;
view: View;
setView: (view: View) => void;
onToggleCollapse: () => void;
}
const Sidebar: React.FC<SidebarProps> = ({ isCollapsed, view, setView, onToggleCollapse }) => (
<aside
className={`flex flex-col bg-white border-r border-gray-200 transition-all duration-300 ease-in-out z-20 ${
isCollapsed ? 'w-20' : 'w-64'
}`}
>
<div className="flex items-center justify-start h-16 border-b border-gray-200 px-4">
<img
src="/favicon.png"
alt="Logo Charoen Pokphand"
className="object-contain flex-shrink-0 h-10"
/>
</div>
<nav className="flex-grow p-2 overflow-y-auto">
<ul className="space-y-1">
{navItems.map((item) => {
const isActive =
view === item.view || (item.subItems?.some((sub) => sub.view === view) ?? false);
return (
<NavLink
key={item.label}
item={item}
isActive={isActive}
isCollapsed={isCollapsed}
setView={setView}
view={view}
/>
);
})}
</ul>
</nav>
<div className="p-2 border-t border-gray-200">
<button
onClick={onToggleCollapse}
className="w-full flex items-center h-11 px-4 rounded-lg text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<div className="w-8 text-lg flex items-center justify-center -ml-1">
<i className={`fa-solid ${isCollapsed ? 'fa-angles-right' : 'fa-angles-left'}`}></i>
</div>
{!isCollapsed && <span className="ml-2 text-sm font-semibold">Tutup Menu</span>}
</button>
</div>
</aside>
);
export default Sidebar;
+313
View File
@@ -0,0 +1,313 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
import type { AIInsight, ChickenWeight, LatestAvg } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { Banner, EmptyFarm, Spinner } from './ui/Feedback.tsx';
import { formatDateId, formatNumber } from '../utils/format.ts';
import { formatWeight } from '../utils/weight.ts';
import WeightStats from './weight/WeightStats.tsx';
import DailyWeightProgressionChart from './weight/DailyWeightProgressionChart.tsx';
import DailyWeightGainChart from './weight/DailyWeightGainChart.tsx';
import DailyDetailPanel from './weight/DailyDetailPanel.tsx';
const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle } = useFarm();
const [rows, setRows] = useState<ChickenWeight[]>([]);
const [latest, setLatest] = useState<LatestAvg | null>(null);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState('');
const reload = async (cycleId: number) => {
setError(null);
try {
const [list, insightRows] = await Promise.all([
api.weights.list({ cycle_id: cycleId }),
api.insights.list({ cycle_id: cycleId }),
]);
setRows(list);
setInsights(insightRows);
try {
setLatest(await api.weights.latestAvg({ cycle_id: cycleId }));
} catch (err) {
if (err instanceof ApiError && err.status === 404) setLatest(null);
else throw err;
}
} catch (err) {
setError(errorMessage(err));
}
};
useEffect(() => {
if (!selectedCycle) {
setRows([]);
setLatest(null);
setInsights([]);
setSelectedDate('');
return;
}
let cancelled = false;
(async () => {
setLoading(true);
setRefreshing(false);
try {
await reload(selectedCycle.id);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCycle]);
// Days without a weighing are stored as 0 g. Charts skip those days so the
// line does not drop to zero at the end of a cycle, mirroring the old
// dashboard which only plotted days with actual timbangan data.
const chartRows = useMemo(() => rows.filter((row) => row.average_weight > 0), [rows]);
// Default the date picker to the newest row with a real weighing once data
// loads, so the detail panel opens with meaningful values.
useEffect(() => {
if (!selectedDate) {
const newestMeaningful = chartRows[0] ?? rows[0];
if (newestMeaningful) setSelectedDate(newestMeaningful.date);
}
}, [rows, chartRows, selectedDate]);
const handleRefresh = async () => {
if (!selectedCycle) return;
setRefreshing(true);
try {
await reload(selectedCycle.id);
} catch {
// error state is set inside reload
} finally {
setRefreshing(false);
}
};
const totalDays = selectedCycle?.total_days ?? 35;
const selectedRow = useMemo(
() => rows.find((row) => row.date === selectedDate) ?? null,
[rows, selectedDate]
);
const minDate = rows.length > 0 ? (rows[rows.length - 1]?.date ?? '') : '';
const maxDate = rows.length > 0 ? (rows[0]?.date ?? '') : '';
const weightInsights = useMemo(() => {
const matched = insights.filter((item) => {
const section = `${item.section ?? ''} ${item.alert ?? ''}`.toLowerCase();
return section.includes('berat') || section.includes('weight') || section.includes('timbang');
});
return (matched.length > 0 ? matched : insights).slice(0, 4);
}, [insights]);
// Prefer the latest non-zero row for the KPI card so a closed cycle with
// trailing empty days does not read as "0 g".
const kpiLatest = useMemo<LatestAvg | null>(() => {
if (!latest) return null;
if (latest.average_weight > 0) return latest;
const fallback = rows.find((row) => row.average_weight > 0);
if (!fallback) return null;
return {
average_weight: fallback.average_weight,
date: fallback.date,
age: fallback.age,
uniformity: fallback.uniformity,
average_daily_gain: fallback.average_daily_gain,
cycle: fallback.cycle,
kandang: fallback.kandang,
};
}, [latest, rows]);
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
return (
<main className="pb-8">
{/* Header */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6">
<div className="flex items-center">
<div className="bg-red-600 text-white p-3 rounded-lg mr-4">
<i className="fa-solid fa-chart-line text-2xl sm:text-3xl"></i>
</div>
<div>
<h1 className="text-2xl lg:text-3xl font-bold text-gray-800">Dashboard Berat Ayam</h1>
<p className="mt-1 text-gray-600">
Analisis pertumbuhan dan kesejahteraan bobot ayam secara real-time.
</p>
{!loading && latest?.date && (
<p className="mt-1 text-sm text-gray-500">
<i className="fa-solid fa-clock mr-1"></i>
Terakhir diperbarui: {formatDateId(latest.date)}
</p>
)}
</div>
</div>
<button
onClick={handleRefresh}
disabled={refreshing || loading}
className="flex items-center space-x-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
title="Refresh data manual"
>
<i className={`fa-solid fa-refresh ${refreshing || loading ? 'fa-spin' : ''}`}></i>
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
</button>
</div>
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
</div>
)}
{loading && rows.length === 0 ? (
<Spinner />
) : (
<div className="space-y-8">
{/* AI Insight */}
<section>
<h2 className="text-lg font-bold text-gray-800 mb-3">AI Insight</h2>
{weightInsights.length === 0 ? (
<Banner>Belum ada insight untuk siklus ini.</Banner>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{weightInsights.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>
{/* TOP SECTION: DATA SELURUH SIKLUS */}
<div className="space-y-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-1">
<WeightStats latest={kpiLatest} totalDays={totalDays} />
</div>
<div className="lg:col-span-2">
<DailyWeightProgressionChart rows={chartRows} totalDays={totalDays} />
</div>
</div>
<DailyWeightGainChart rows={chartRows} totalDays={totalDays} />
</div>
{/* ===== SEPARATOR: DATA PER HARI ===== */}
<div className="border-t-4 border-gray-300 pt-8 mt-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-800">Data Per Hari</h2>
<p className="text-sm text-gray-600 mt-1">
Pilih tanggal untuk melihat detail data harian
</p>
</div>
<div className="flex items-center space-x-3 bg-white px-4 py-2 rounded-lg border border-gray-300 shadow-sm">
<i className="fa-solid fa-calendar text-red-600"></i>
<div>
<label htmlFor="daily-date-picker" className="text-xs text-gray-500 block mb-1">
Pilih Tanggal:
</label>
<input
id="daily-date-picker"
type="date"
value={selectedDate}
min={minDate || undefined}
max={maxDate || undefined}
onChange={(e) => setSelectedDate(e.target.value)}
className="px-2 py-1 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
</div>
</div>
</div>
<DailyDetailPanel row={selectedRow} totalDays={totalDays} />
{/* Riwayat lengkap */}
<div className="mt-6">
<h3 className="text-lg font-bold text-gray-800 mb-3">Riwayat Berat Harian</h3>
<div className="bg-white rounded-xl shadow-sm overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 text-gray-600">
<tr>
{['Tanggal', 'Umur', 'DOC', 'AVG', 'Uniformity', 'ADG'].map((h) => (
<th key={h} className="px-4 py-3 text-left font-semibold">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-6 text-center text-gray-400">
Belum ada data berat untuk siklus ini.
</td>
</tr>
) : (
rows.map((row) => (
<tr key={row.id} className="border-t">
<td className="px-4 py-2">{formatDateId(row.date)}</td>
<td className="px-4 py-2">{row.age}</td>
<td className="px-4 py-2">
{formatWeight(row.doc_weight, { decimals: 0 })}
</td>
<td className="px-4 py-2 font-semibold">
{formatWeight(row.average_weight, { stripTrailingZeros: true })}
</td>
<td className="px-4 py-2">{formatNumber(row.uniformity, 2, 2, true)}%</td>
<td className="px-4 py-2">
{formatNumber(row.average_daily_gain, 2, 2, true)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start">
<i className="fa-solid fa-info-circle text-blue-600 text-xl mr-3 mt-1"></i>
<div>
<h3 className="font-semibold text-blue-900 mb-2">Informasi Pembaruan Data</h3>
<ul className="text-sm text-blue-800 space-y-1">
<li>• Klik tombol "Refresh" untuk memperbarui data secara manual</li>
<li>• Grafik menampilkan data dari awal siklus hingga hari ini</li>
<li>• Target berat menggunakan standar performa Ross 308</li>
</ul>
</div>
</div>
</div>
</div>
)}
</main>
);
};
export default WeightMonitoringPage;
+62
View File
@@ -0,0 +1,62 @@
import React from 'react';
import { weightStandardData } from '../mockData/weightStandardData.ts';
const WeightStandardPage: React.FC = () => {
const { headers, data } = weightStandardData;
return (
<main className="px-4 sm:px-6 lg:px-8 pb-8">
<div className="flex items-center mb-6">
<i className="fa-solid fa-table-list text-3xl text-red-600 mr-4"></i>
<div>
<h1 className="text-3xl font-bold text-gray-800">Tabel Standar Bobot Ross 308</h1>
<p className="mt-1 text-gray-600">
Driven from pen trial data - so difference may be more
</p>
</div>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm overflow-hidden">
<div className="overflow-auto max-h-[calc(100vh-250px)]">
<table className="min-w-full border-collapse text-sm">
<thead className="bg-red-700 text-white sticky top-0 z-10">
<tr>
<th className="p-2 border border-red-800 font-semibold sticky left-0 bg-red-700 z-20">
DO/wt
</th>
{headers.map((header) => (
<th key={header} className="p-2 border border-red-800 font-semibold w-16">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr key={rowIndex} className="even:bg-red-100/50">
<td className="p-1 border border-red-200 text-center bg-red-700 text-white font-semibold sticky left-0 z-10">
{row.day}
</td>
{row.values.map((value, colIndex) => {
const isRedText = (colIndex + 1) % 7 === 0;
return (
<td
key={colIndex}
className={`p-1 border border-red-200 text-center font-mono ${isRedText ? 'text-red-600 font-bold' : 'text-gray-800'}`}
>
{value}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="text-right mt-2 text-xs text-gray-500 font-semibold">AVIAGEN PROPRIETARY</div>
</main>
);
};
export default WeightStandardPage;
@@ -0,0 +1,195 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import FeedSackCountingPage from '../FeedSackCountingPage.tsx';
const { karungRows, manualRows, cycle } = vi.hoisted(() => {
const cycle = {
id: 1,
kandang: 1,
flock: 1,
total_days: 35,
current_day: 2,
start_date: '2026-08-10',
end_date: '2026-09-13',
chick_in_weight: 45,
doc_in_count: 20000,
status: 'active',
created_at: '',
updated_at: '',
};
const karungRows = [
{
id: 1,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-08-10',
in_today: 10,
out_today: 4,
in_total: 10,
out_total: 4,
feed_use_today: 4,
feed_use_total: 4,
created_at: '',
updated_at: '',
},
{
id: 2,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-08-11',
in_today: 12,
out_today: 6,
in_total: 22,
out_total: 10,
feed_use_today: 6,
feed_use_total: 10,
created_at: '',
updated_at: '',
},
];
const manualRows = [
{
id: 1,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-08-10',
age_manual: 0,
mortality_manual: 0,
mortality_manual_total: 0,
feed_in_manual: 10,
feed_use_manual: 4,
feed_in_manual_total: 10,
feed_use_manual_total: 4,
feed_out_manual: 0,
feed_out_manual_total: 0,
harvest_manual: 0,
harvest_manual_total: 0,
harvest_weight_manual: 0,
harvest_weight_manual_total: 0,
manual_weight: 0,
average_harvest_day_manual: 0,
average_harvest_weight_manual: 0,
fcr_manual: 0,
eef_manual: 0,
created_at: '',
updated_at: '',
},
{
id: 2,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-08-11',
age_manual: 1,
mortality_manual: 0,
mortality_manual_total: 0,
feed_in_manual: 10,
feed_use_manual: 7,
feed_in_manual_total: 20,
feed_use_manual_total: 11,
feed_out_manual: 0,
feed_out_manual_total: 0,
harvest_manual: 0,
harvest_manual_total: 0,
harvest_weight_manual: 0,
harvest_weight_manual_total: 0,
manual_weight: 0,
average_harvest_day_manual: 0,
average_harvest_weight_manual: 0,
fcr_manual: 0,
eef_manual: 0,
created_at: '',
updated_at: '',
},
];
return { karungRows, manualRows, cycle };
});
vi.mock('../../services/apiClient.ts', () => ({
api: {
karungs: {
list: vi.fn().mockResolvedValue(karungRows),
request: vi.fn().mockResolvedValue({ karung: {}, upstream: {} }),
},
insights: {
list: vi.fn().mockResolvedValue([]),
},
manualInputs: {
list: vi.fn().mockResolvedValue(manualRows),
},
},
errorMessage: (err: unknown) => (err instanceof Error ? err.message : 'Terjadi kesalahan'),
}));
vi.mock('../../context/FarmContext.tsx', () => ({
useFarm: () => ({
selectedSite: { id: 1, site_name: 'Sukawarna', user: 1, created_at: '', updated_at: '' },
selectedKandang: {
id: 1,
kandang_name: 'Kandang 1',
site: 1,
created_at: '',
updated_at: '',
},
selectedCycle: cycle,
cycles: [cycle],
setSelectedCycleId: vi.fn(),
}),
}));
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
describe('FeedSackCountingPage', () => {
it('renders the recreated hitung karung view with stats, saldo, and daily table', async () => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
try {
render(<FeedSackCountingPage onSettings={() => undefined} />);
expect(await screen.findByRole('heading', { name: 'Hitung Karung' })).toBeInTheDocument();
expect(
screen.getByText('Monitoring karung masuk dan karung dituang per hari')
).toBeInTheDocument();
expect(screen.getByText(/10 Agu 2026 - 13 Sep 2026/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Sync hari ini/i })).toBeInTheDocument();
// Ringkasan stats
expect(screen.getAllByText('Karung Masuk').length).toBeGreaterThan(0);
expect(screen.getAllByText('Karung Dituang').length).toBeGreaterThan(0);
expect(screen.getAllByText('Karung Keluar').length).toBeGreaterThan(0);
expect(screen.getAllByText('12').length).toBeGreaterThan(0); // in_today dari snapshot terbaru
// Saldo card
expect(screen.getByRole('heading', { name: 'Saldo Karung IOT' })).toBeInTheDocument();
// Trend chart
expect(screen.getByRole('heading', { name: 'Tren Harian Karung IOT' })).toBeInTheDocument();
// Daily table (separate masuk & tuang boxes, each with Manual/IOT/Akurasi)
expect(
screen.getByRole('heading', { name: 'Rincian Harian Karung IOT' })
).toBeInTheDocument();
expect(screen.getAllByText('KARUNG MASUK').length).toBeGreaterThan(0);
expect(screen.getAllByText('KARUNG TUANG').length).toBeGreaterThan(0);
// Headers appear in both boxes
expect(screen.getAllByText('Manual').length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText('IOT').length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText('Akurasi').length).toBeGreaterThanOrEqual(2);
// Total columns still show snapshot totals
expect(screen.getAllByText('22').length).toBeGreaterThan(0);
expect(screen.getAllByText('10').length).toBeGreaterThan(0);
// Accuracy: hari ke-2 masuk manual 10 vs IOT 12 -> 83.3%
expect(screen.getAllByText('83.3%').length).toBeGreaterThan(0);
// Hari ke-1 matching values -> 100.0%
expect(screen.getAllByText('100.0%').length).toBeGreaterThan(0);
} finally {
vi.unstubAllGlobals();
}
});
});
+145
View File
@@ -0,0 +1,145 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import SettingsPage from '../SettingsPage.tsx';
const { createKandangMock, createCycleMock, reloadMock, farmState } = vi.hoisted(() => ({
createKandangMock: vi.fn(),
createCycleMock: vi.fn(),
reloadMock: vi.fn().mockResolvedValue(undefined),
farmState: {
sites: [{ id: 7, site_name: 'Site A', user: 99, created_at: '', updated_at: '' }],
kandangs: [] as Array<{
id: number;
kandang_name: string;
site: number;
created_at: string;
updated_at: string;
}>,
cycles: [] as unknown[],
selectedSiteId: 7,
selectedKandangId: null as number | null,
selectedCycle: null as unknown,
},
}));
vi.mock('../../services/apiClient.ts', () => ({
api: {
sites: {
create: vi.fn(),
remove: vi.fn(),
},
kandangs: {
create: createKandangMock,
remove: vi.fn(),
},
cycles: {
create: createCycleMock,
remove: vi.fn(),
},
},
errorMessage: (err: unknown) => (err instanceof Error ? err.message : 'Terjadi kesalahan'),
}));
vi.mock('../../context/AuthContext.tsx', () => ({
useAuth: () => ({
user: {
id: 99,
user_name: 'tester',
status: 'superadmin',
created_at: '',
updated_at: '',
},
}),
}));
vi.mock('../../context/FarmContext.tsx', () => ({
useFarm: () => ({
sites: farmState.sites,
kandangs: farmState.kandangs,
cycles: farmState.cycles,
selectedSiteId: farmState.selectedSiteId,
selectedKandangId: farmState.selectedKandangId,
selectedCycle: farmState.selectedCycle,
reload: reloadMock,
setSelectedSiteId: vi.fn(),
setSelectedKandangId: vi.fn(),
setSelectedCycleId: vi.fn(),
}),
}));
describe('SettingsPage settings forms', () => {
beforeEach(() => {
createKandangMock.mockReset();
createCycleMock.mockReset();
reloadMock.mockClear();
farmState.kandangs = [];
farmState.cycles = [];
farmState.selectedSiteId = 7;
farmState.selectedKandangId = null;
farmState.selectedCycle = null;
createKandangMock.mockResolvedValue({
id: 11,
kandang_name: 'Kandang 1',
site: 7,
created_at: '',
updated_at: '',
});
createCycleMock.mockResolvedValue({
id: 21,
kandang: 11,
flock: 3,
total_days: 35,
current_day: 1,
start_date: '2026-08-18',
end_date: '2026-09-21',
chick_in_weight: 45,
doc_in_count: 20000,
status: 'active',
created_at: '',
updated_at: '',
});
});
it('submits only backend-supported kandang fields', async () => {
render(<SettingsPage />);
fireEvent.change(screen.getByPlaceholderText('Nama kandang'), {
target: { value: 'Kandang 1' },
});
fireEvent.click(screen.getByRole('button', { name: 'Tambah kandang' }));
await waitFor(() => expect(createKandangMock).toHaveBeenCalledTimes(1));
expect(createKandangMock).toHaveBeenCalledWith({
kandang_name: 'Kandang 1',
site: 7,
});
});
it('submits cycle fields expected by the backend contract', async () => {
farmState.kandangs = [
{ id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' },
];
farmState.selectedKandangId = 11;
render(<SettingsPage />);
expect(screen.queryByLabelText(/Total hari/i)).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/Mulai/i), { target: { value: '2026-08-01' } });
fireEvent.change(screen.getByLabelText(/Selesai/i), { target: { value: '2026-09-04' } });
fireEvent.click(screen.getByRole('button', { name: 'Tambah siklus' }));
await waitFor(() => expect(createCycleMock).toHaveBeenCalledTimes(1));
expect(createCycleMock).toHaveBeenCalledWith({
kandang: 11,
total_days: 35,
current_day: 1,
start_date: '2026-08-01',
end_date: '2026-09-04',
chick_in_weight: 45,
doc_in_count: 20000,
status: 'active',
});
});
});
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
import LoginForm from './LoginForm.tsx';
const AuthPage: React.FC = () => (
<div className="min-h-screen bg-gray-100 flex flex-col justify-center items-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<img
src="https://www.cpp.co.id/wp-content/uploads/2021/08/logo-PT-Charoen-Pokphand-Indonesia-Tbk.png"
alt="Charoen Pokphand Logo"
className="h-16 mx-auto mb-4"
/>
<h1 className="text-2xl font-bold text-gray-800">Solusi AI Peternakan Ayam</h1>
</div>
<div className="bg-white p-8 rounded-2xl shadow-xl">
<LoginForm />
</div>
</div>
</div>
);
export default AuthPage;
+78
View File
@@ -0,0 +1,78 @@
import React, { useState } from 'react';
import { useAuth } from '../../context/AuthContext.tsx';
import { errorMessage } from '../../services/apiClient.ts';
const LoginForm: React.FC = () => {
const { login } = useAuth();
const [userName, setUserName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login(userName, password);
} catch (err) {
setError(errorMessage(err));
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-6">
{error && (
<div className="rounded-lg bg-red-50 text-red-700 text-sm px-3 py-2 border border-red-100">
{error}
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<div className="mt-1">
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
value={userName}
onChange={(e) => setUserName(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-red-500 focus:border-red-500 sm:text-sm"
placeholder="Nama pengguna"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Kata Sandi
</label>
<div className="mt-1">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-red-500 focus:border-red-500 sm:text-sm"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-semibold text-white bg-red-600 hover:bg-red-700 disabled:opacity-60"
>
{isLoading ? 'Masuk...' : 'Masuk'}
</button>
</form>
);
};
export default LoginForm;
+55
View File
@@ -0,0 +1,55 @@
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught error:', error, errorInfo);
// TODO: Send to error tracking service (Sentry, LogRocket, etc.)
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md text-center">
<i className="fa-solid fa-triangle-exclamation text-red-500 text-5xl mb-4"></i>
<h2 className="text-2xl font-bold text-gray-800 mb-2">Something went wrong</h2>
<p className="text-gray-600 mb-4">
We&apos;re sorry, but something unexpected happened. Please refresh the page.
</p>
<button
onClick={() => window.location.reload()}
className="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Refresh Page
</button>
</div>
</div>
)
);
}
return this.props.children;
}
}
export default ErrorBoundary;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,119 @@
import React from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { formatNumber } from '../../utils/format.ts';
export type PopulationPoint = {
date: string;
population: number;
chickenCount?: number;
};
type Props = {
data: PopulationPoint[];
title?: string;
};
function yTick(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}jt`;
if (value >= 1_000) return `${Math.round(value / 1_000)}rb`;
return `${value}`;
}
const PopulationTrendChart: React.FC<Props> = ({ data, title = 'Tren Populasi' }) => {
if (!data || data.length === 0) {
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h3 className="text-lg font-bold text-gray-800 mb-4">{title}</h3>
<div className="h-64 flex items-center justify-center text-gray-400">
<div className="text-center">
<i className="fa-solid fa-chart-line text-4xl mb-2"></i>
<p>Tidak ada data tersedia</p>
</div>
</div>
</div>
);
}
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h3 className="text-lg font-bold text-gray-800 mb-2">{title}</h3>
{/* Legend: explicit color explanation */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 mb-3 text-xs">
<span className="inline-flex items-center gap-2">
<span className="inline-block w-5 h-[3px] rounded-full bg-red-500"></span>
<span className="font-semibold text-red-700">Populasi (Stock Akhir Manual)</span>
</span>
<span className="inline-flex items-center gap-2">
<span className="inline-block w-5 h-[3px] rounded-full bg-blue-500"></span>
<span className="font-semibold text-blue-700">Chicken Counting (IOT)</span>
</span>
</div>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis
dataKey="date"
fontSize={11}
tick={{ fill: '#6b7280' }}
interval="preserveStartEnd"
/>
<YAxis fontSize={11} tick={{ fill: '#6b7280' }} tickFormatter={yTick} />
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #e0e0e0',
borderRadius: '8px',
padding: '8px 12px',
}}
formatter={(value, name) => {
const num = Number(value ?? 0);
return [num === 0 ? '0' : formatNumber(num, 0), name];
}}
labelStyle={{ fontWeight: 600, marginBottom: '4px' }}
/>
<Line
type="monotone"
dataKey="population"
stroke="#ef4444"
strokeWidth={2.5}
name="Populasi (Stock Akhir Manual)"
dot={{ r: 3, fill: '#ef4444' }}
activeDot={{ r: 5, strokeWidth: 2, stroke: '#fff' }}
/>
<Line
type="monotone"
dataKey="chickenCount"
stroke="#3b82f6"
strokeWidth={2.5}
name="Chicken Counting (IOT)"
dot={{ r: 3, fill: '#3b82f6' }}
activeDot={{ r: 5, strokeWidth: 2, stroke: '#fff' }}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
<p className="mt-3 text-xs text-gray-500">
<i className="fa-solid fa-circle-info mr-1 text-gray-400"></i>
Garis <span className="font-semibold text-red-700">merah</span> = populasi hasil perhitungan
manual (stock akhir), garis <span className="font-semibold text-blue-700">biru</span> =
hasil hitung otomatis sensor IOT. Semakin berdekatan kedua garis, semakin akurat hitungan
IOT.
</p>
</div>
);
};
export default PopulationTrendChart;
@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import DataManagementPage from '../DataManagementPage.tsx';
vi.mock('../../../services/apiClient.ts', () => ({
api: {
kpis: {
list: vi.fn().mockResolvedValue([]),
},
countings: {
list: vi.fn().mockResolvedValue([]),
create: vi.fn(),
update: vi.fn(),
},
manualInputs: {
list: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
insights: {
list: vi.fn().mockResolvedValue([]),
},
},
errorMessage: (err: unknown) => (err instanceof Error ? err.message : 'Terjadi kesalahan'),
}));
vi.mock('../../../context/FarmContext.tsx', () => ({
useFarm: () => ({
selectedSite: { id: 1, site_name: 'Sukawarna', user: 1, created_at: '', updated_at: '' },
selectedKandang: {
id: 1,
kandang_name: 'Kandang 1',
site: 1,
created_at: '',
updated_at: '',
},
selectedCycle: {
id: 1,
kandang: 1,
flock: 1,
total_days: 35,
current_day: 3,
start_date: '2026-08-10',
end_date: '2026-09-13',
chick_in_weight: 45,
doc_in_count: 20000,
status: 'active',
created_at: '',
updated_at: '',
},
}),
}));
describe('DataManagementPage', () => {
it('renders the Hitung Ayam dashboard view with stats and daily rows', async () => {
render(<DataManagementPage onSettings={() => undefined} />);
expect(await screen.findByRole('heading', { name: 'Hitung Ayam' })).toBeInTheDocument();
expect(screen.getByText(/Populasi Awal/i)).toBeInTheDocument();
expect(screen.getByText(/Total Mortalitas/i)).toBeInTheDocument();
expect(screen.getByText(/Tingkat Mortalitas/i)).toBeInTheDocument();
expect(screen.getAllByText(/Hari 0/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Hari 2/i).length).toBeGreaterThan(0);
});
it('does not offer manual or counting input forms', async () => {
render(<DataManagementPage onSettings={() => undefined} />);
expect(await screen.findByRole('heading', { name: 'Hitung Ayam' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /Input manual/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Simpan hitungan/i })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,265 @@
import React, { useEffect, useState } from 'react';
import { api, errorMessage } from '../../services/apiClient.ts';
import type { ManualInput, ManualInputWrite } from '../../types/api.ts';
import { Banner } from '../ui/Feedback.tsx';
import { formatNumber, todayIso } from '../../utils/format.ts';
const inputClass =
'border border-gray-300 rounded-md px-3 py-2 text-sm w-full focus:ring-red-500 focus:border-red-500';
type Props = {
cycleId: number;
currentDay: number;
readOnly?: boolean;
};
type FormState = {
date: string;
age_manual: number;
mortality_manual: number;
feed_in_manual: number;
feed_use_manual: number;
harvest_manual: number;
harvest_weight_manual: number;
manual_weight: number;
};
const emptyForm = (currentDay: number): FormState => ({
date: todayIso(),
age_manual: currentDay,
mortality_manual: 0,
feed_in_manual: 0,
feed_use_manual: 0,
harvest_manual: 0,
harvest_weight_manual: 0,
manual_weight: 0,
});
function toForm(row: ManualInput): FormState {
return {
date: row.date,
age_manual: row.age_manual,
mortality_manual: row.mortality_manual,
feed_in_manual: row.feed_in_manual,
feed_use_manual: row.feed_use_manual,
harvest_manual: row.harvest_manual,
// Database dalam gram, form selalu dalam kg.
harvest_weight_manual: row.harvest_weight_manual / 1000,
manual_weight: row.manual_weight,
};
}
const DashboardManualInputForm: React.FC<Props> = ({ cycleId, currentDay, readOnly = false }) => {
const [form, setForm] = useState<FormState>(() => emptyForm(currentDay));
const [saved, setSaved] = useState<ManualInput | null>(null);
const [rows, setRows] = useState<ManualInput[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const load = async (date: string) => {
const list = await api.manualInputs.list({ cycle_id: cycleId });
setRows(list);
const match = list.find((row) => row.date === date) ?? null;
setSaved(match);
if (match) setForm(toForm(match));
else {
setForm((prev) => ({
...emptyForm(currentDay),
date,
age_manual: prev.date === date ? prev.age_manual : currentDay,
}));
}
};
useEffect(() => {
let cancelled = false;
(async () => {
try {
const list = await api.manualInputs.list({ cycle_id: cycleId });
if (cancelled) return;
setRows(list);
const today = todayIso();
const match =
list.find((row) => row.date === today) ?? (readOnly ? (list[0] ?? null) : null);
setSaved(match);
setForm(match ? toForm(match) : emptyForm(currentDay));
} catch (err) {
if (!cancelled) setError(errorMessage(err));
}
})();
return () => {
cancelled = true;
};
}, [cycleId, currentDay, readOnly]);
const onDateChange = async (date: string) => {
setError(null);
setNotice(null);
try {
await load(date);
} catch (err) {
setError(errorMessage(err));
}
};
return (
<section className="mt-8">
<form
onSubmit={(e) => {
e.preventDefault();
if (readOnly) return;
void (async () => {
setSaving(true);
setError(null);
setNotice(null);
try {
const body: ManualInputWrite = {
cycle: cycleId,
date: form.date,
age_manual: form.age_manual,
mortality_manual: form.mortality_manual,
feed_in_manual: form.feed_in_manual,
feed_use_manual: form.feed_use_manual,
harvest_manual: form.harvest_manual,
// Form dalam kg, disimpan ke database dalam gram.
harvest_weight_manual: form.harvest_weight_manual * 1000,
manual_weight: form.manual_weight,
};
const row = await api.manualInputs.create(body);
setSaved(row);
setForm(toForm(row));
setRows((prev) => {
const others = prev.filter((item) => item.date !== row.date);
return [row, ...others];
});
setNotice('Input manual tersimpan.');
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
})();
}}
className="bg-white rounded-xl shadow-sm p-5 space-y-4"
>
<h2 className="text-lg font-bold text-gray-800">Input manual</h2>
<p className="text-sm text-gray-500">
{readOnly
? 'Siklus ini sudah ditutup. Data di bawah hanya untuk ditinjau.'
: 'Isi data harian di sini. Total dihitung otomatis. FCR, EEF, dan rata-rata panen belum dihitung.'}
</p>
{error && <Banner kind="error">{error}</Banner>}
{notice && <Banner kind="success">{notice}</Banner>}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3">
<label className="block text-sm">
Tanggal
<input
type="date"
className={`${inputClass} mt-1`}
value={form.date}
onChange={(e) => void onDateChange(e.target.value)}
/>
</label>
<label className="block text-sm">
Umur (hari)
<input
type="number"
className={`${inputClass} mt-1`}
value={form.age_manual}
disabled={readOnly}
onChange={(e) => setForm((f) => ({ ...f, age_manual: Number(e.target.value) }))}
/>
</label>
<label className="block text-sm">
Mortalitas (ekor)
<input
type="number"
className={`${inputClass} mt-1`}
value={form.mortality_manual}
disabled={readOnly}
onChange={(e) => setForm((f) => ({ ...f, mortality_manual: Number(e.target.value) }))}
/>
</label>
<label className="block text-sm">
Karung masuk
<input
type="number"
className={`${inputClass} mt-1`}
value={form.feed_in_manual}
disabled={readOnly}
onChange={(e) => setForm((f) => ({ ...f, feed_in_manual: Number(e.target.value) }))}
/>
</label>
<label className="block text-sm">
Karung Tuang
<input
type="number"
className={`${inputClass} mt-1`}
value={form.feed_use_manual}
disabled={readOnly}
onChange={(e) => setForm((f) => ({ ...f, feed_use_manual: Number(e.target.value) }))}
/>
</label>
<label className="block text-sm">
Panen (ekor)
<input
type="number"
className={`${inputClass} mt-1`}
value={form.harvest_manual}
disabled={readOnly}
onChange={(e) => setForm((f) => ({ ...f, harvest_manual: Number(e.target.value) }))}
/>
</label>
<label className="block text-sm">
Berat panen (kg)
<input
type="number"
step="0.01"
className={`${inputClass} mt-1`}
value={form.harvest_weight_manual}
disabled={readOnly}
onChange={(e) =>
setForm((f) => ({ ...f, harvest_weight_manual: Number(e.target.value) }))
}
/>
</label>
<label className="block text-sm">
Berat AVG
<input
type="number"
step="0.01"
className={`${inputClass} mt-1`}
value={form.manual_weight}
disabled={readOnly}
onChange={(e) => setForm((f) => ({ ...f, manual_weight: Number(e.target.value) }))}
/>
</label>
</div>
{saved && (
<p className="text-xs text-gray-500">
Total mortalitas {formatNumber(saved.mortality_manual_total, 0)} · karung masuk{' '}
{formatNumber(saved.feed_in_manual_total, 0)} · karung tuang{' '}
{formatNumber(saved.feed_use_manual_total, 0)} · panen{' '}
{formatNumber(saved.harvest_manual_total, 0)} · berat panen{' '}
{formatNumber(saved.harvest_weight_manual_total / 1000, 2)} kg
</p>
)}
{!saved && rows.length > 0 && (
<p className="text-xs text-gray-400">Belum ada input untuk tanggal ini.</p>
)}
{!readOnly && (
<button
type="submit"
disabled={saving}
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-60"
>
{saving ? 'Menyimpan...' : 'Simpan'}
</button>
)}
</form>
</section>
);
};
export default DashboardManualInputForm;
@@ -0,0 +1,43 @@
import React from 'react';
export interface DashboardReportCtasProps {
cycleClosed: boolean;
hasDailySnapshot: boolean;
hasEndCycleSnapshot: boolean;
onOpenDaily: () => void;
onOpenEndCycle: () => void;
}
const buttonClass =
'rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50';
const label = (hasSnapshot: boolean) => (hasSnapshot ? 'Lihat' : 'Buat');
const DashboardReportCtas: React.FC<DashboardReportCtasProps> = ({
cycleClosed,
hasDailySnapshot,
hasEndCycleSnapshot,
onOpenDaily,
onOpenEndCycle,
}) => (
<div className="flex flex-wrap items-end justify-end gap-3">
<button type="button" onClick={onOpenDaily} className={buttonClass}>
{label(hasDailySnapshot)} Harian
</button>
<div className="flex flex-col items-end">
<button
type="button"
onClick={onOpenEndCycle}
disabled={!cycleClosed}
className={buttonClass}
>
{label(hasEndCycleSnapshot)} Akhir Siklus
</button>
{!cycleClosed && (
<p className="mt-1 text-xs text-slate-500">Tersedia setelah siklus ditutup</p>
)}
</div>
</div>
);
export default DashboardReportCtas;
@@ -0,0 +1,566 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, errorMessage } from '../../services/apiClient.ts';
import { useFarm } from '../../context/FarmContext.tsx';
import { buildInMemoryReport } from '../../utils/buildInMemoryReport.ts';
import { downloadReportExcel } from '../../utils/reportExcel.ts';
import { exportElementToPdf } from '../../utils/pdfExport.ts';
import {
countingTotals,
feedInTotals,
feedOutTotals,
feedUseTotals,
} from '../../utils/reportTotals.ts';
import {
cycleDayForDate,
formatDateId,
formatDateRange,
formatNumber,
formatRatio,
} from '../../utils/format.ts';
import type {
DashboardReportFcrRow,
DashboardReportPayload,
DashboardReportType,
} from '../../types/dashboardReport.ts';
import { Banner } from '../ui/Feedback.tsx';
type Props = {
open: boolean;
reportType: DashboardReportType;
onClose: () => void;
};
const cell = (value: number | string | null | undefined, suffix = ''): string => {
if (value === null || value === undefined || value === '') return 'N/A';
if (typeof value === 'number' && !Number.isFinite(value)) return 'N/A';
return `${value}${suffix}`;
};
const dashCell = (value: number | null | undefined): string =>
value == null || !Number.isFinite(value) ? '-' : String(value);
const accuracyCell = (value: number | null | undefined): string =>
value == null || !Number.isFinite(value) ? 'N/A' : `${value.toFixed(1)}%`;
const ratioCell = (value: number | null | undefined): string =>
value == null || !Number.isFinite(value) ? 'N/A' : formatRatio(Number(value), 3);
const qtyCell = (value: number | null | undefined, maxDecimals = 0, suffix = ''): string => {
if (value == null || !Number.isFinite(value)) return 'N/A';
return `${formatNumber(Number(value), maxDecimals, 0, true)}${suffix}`;
};
const harvestKgCell = (grams: number | null | undefined): string => {
if (grams == null || !Number.isFinite(grams)) return 'N/A';
return `${formatNumber(Number(grams) / 1000, 1)} kg`;
};
const latestValidRatio = (
rows: Array<DashboardReportFcrRow | { iotEef: number | null }>,
key: 'iotFcr' | 'iotEef'
): number | null => {
for (let i = rows.length - 1; i >= 0; i -= 1) {
const value = (rows[i] as Record<string, number | null>)[key];
if (value != null && Number.isFinite(value)) return value;
}
return null;
};
const ReportPreviewOverlay: React.FC<Props> = ({ open, reportType, onClose }) => {
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
const isEndCycle = reportType === 'end_cycle';
const [draftDay, setDraftDay] = useState(0);
const [payload, setPayload] = useState<DashboardReportPayload | null>(null);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const printRef = useRef<HTMLDivElement>(null);
// cycle.current_day is 1-based (day 1 = start_date); report days are 0-based.
const latestDay = selectedCycle ? Math.max(0, selectedCycle.current_day - 1) : 0;
const endCycleDay = selectedCycle
? Math.min(
selectedCycle.total_days,
cycleDayForDate(selectedCycle.start_date, selectedCycle.end_date)
)
: 0;
const effectiveUpToDay = isEndCycle ? endCycleDay : draftDay;
const dayOptions = useMemo(
() => Array.from({ length: latestDay + 1 }, (_, day) => day),
[latestDay]
);
useEffect(() => {
if (!open) return;
setPayload(null);
setError(null);
setGenerating(false);
setDraftDay(isEndCycle ? endCycleDay : latestDay);
}, [open, isEndCycle, latestDay, endCycleDay]);
const generate = useCallback(async () => {
if (!selectedCycle || !selectedKandang) return;
setGenerating(true);
setError(null);
try {
const cycleId = selectedCycle.id;
const [countings, manuals, weights, karungs, kpis] = await Promise.all([
api.countings.list({ cycle_id: cycleId }),
api.manualInputs.list({ cycle_id: cycleId }),
api.weights.list({ cycle_id: cycleId }),
api.karungs.list({ cycle_id: cycleId }),
api.kpis.list({ cycle_id: cycleId }),
]);
const next = buildInMemoryReport({
cycle: selectedCycle,
kandangId: selectedKandang.id,
kandangName: selectedKandang.kandang_name,
area: selectedSite?.site_name ?? '',
reportType,
countings,
manuals,
weights,
karungs,
kpis,
upToDay: effectiveUpToDay,
});
setPayload(next);
} catch (err) {
setError(errorMessage(err));
} finally {
setGenerating(false);
}
}, [reportType, selectedCycle, selectedKandang, selectedSite, effectiveUpToDay]);
const downloadPdf = useCallback(async () => {
if (!printRef.current || !payload) return;
const period = isEndCycle ? 'Akhir-Siklus' : `Harian-Hari-${payload.upToDay}`;
const filename = `Laporan-${period}-${payload.area}-${payload.kandangName}-${payload.cycleStart}_sd_${payload.cycleEnd}.pdf`;
await exportElementToPdf(printRef.current, filename);
}, [payload, isEndCycle]);
if (!open) return null;
const title = isEndCycle ? 'Laporan Akhir Siklus' : 'Laporan Harian';
const cycleLabel = selectedCycle
? `Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`
: '';
const countingTotal = countingTotals(payload?.counting || []);
const feedInTotal = feedInTotals(payload?.feedIn || []);
const feedUseTotal = feedUseTotals(payload?.feedUse || []);
const feedOutTotal = feedOutTotals(payload?.feedOut || []);
const endDateLabel = selectedCycle ? formatDateId(selectedCycle.start_date) : '';
const dataDates =
selectedCycle && payload
? `${endDateLabel} – ${formatDateId(payload.counting[payload.counting.length - 1]?.date ?? selectedCycle.start_date)}`
: '';
const cycleRange = selectedCycle
? formatDateRange(selectedCycle.start_date, selectedCycle.end_date)
: '';
const printedDate = new Date().toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
return (
<div className="fixed inset-0 z-50 bg-slate-900/60" role="dialog" aria-modal="true">
<div className="flex h-full w-full flex-col bg-slate-50">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-4 py-3 sm:px-6">
<div>
<h2 id="report-preview-title" className="text-lg font-semibold text-slate-900">
{title}
</h2>
<p className="text-xs text-slate-500">
{selectedKandang?.kandang_name ?? 'Pilih kandang'} · {cycleLabel} · Hari ke-0 s/d ke-
{effectiveUpToDay}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{!isEndCycle && (
<>
<label className="sr-only" htmlFor="report-day">
Hari siklus
</label>
<select
id="report-day"
aria-label="Hari siklus"
value={draftDay}
onChange={(event) => setDraftDay(Number(event.target.value))}
className="rounded-lg border border-slate-200 bg-white px-2 py-1.5 text-xs font-semibold text-slate-700 shadow-sm"
>
{dayOptions.map((day) => (
<option key={day} value={day}>
Hari {day}
</option>
))}
</select>
</>
)}
<button
type="button"
onClick={() => void generate()}
disabled={!selectedCycle || !selectedKandang || generating}
className="rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{generating ? 'Memproses…' : payload ? 'Perbarui' : 'Generate'}
</button>
<button
type="button"
onClick={() => payload && void downloadPdf()}
disabled={!payload}
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
>
PDF
</button>
<button
type="button"
onClick={() => payload && void downloadReportExcel(payload)}
disabled={!payload}
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Excel
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50"
aria-label="Tutup"
>
<i className="fa-solid fa-xmark" />
</button>
</div>
</header>
<div className="flex-1 overflow-auto px-4 py-4 sm:px-6">
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
</div>
)}
<div
id="report-snapshot-print"
ref={printRef}
className="rounded-xl bg-white p-4 shadow-sm"
>
{!payload ? (
<p className="py-8 text-center text-sm text-slate-500">
Tekan <strong>Generate</strong> untuk membuat laporan dari data siklus saat ini.
</p>
) : (
<>
<div className="mb-6 border-b-2 border-purple-700 pb-4">
<div style={{ fontSize: 17, fontWeight: 700, color: '#111827' }}>{title}</div>
<div style={{ fontSize: 13, color: '#374151', marginTop: 5 }}>
<strong>Lokasi:</strong> {payload.area} — {payload.kandangName}
</div>
<div style={{ fontSize: 13, color: '#374151', marginTop: 2 }}>
<strong>Periode:</strong> Hari ke-0 s/d ke-{payload.upToDay} {dataDates}
</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>
<strong>Siklus:</strong> {cycleRange}
</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>
Dicetak: {printedDate}
</div>
{isEndCycle && (
<div style={{ fontSize: 22, color: '#111827', marginTop: 12, fontWeight: 700 }}>
FCR akhir: {ratioCell(latestValidRatio(payload.fcr, 'iotFcr'))} · EEF akhir:{' '}
{ratioCell(latestValidRatio(payload.eef, 'iotEef'))}
</div>
)}
</div>
<ReportTable title="Hitung Ayam harian">
<thead>
<tr>
<Th>Tanggal</Th>
<Th>Umur (hari)</Th>
<Th>Stok awal</Th>
<Th>Mortalitas</Th>
<Th>Panen</Th>
<Th>Berat panen</Th>
<Th>Stok akhir</Th>
<Th>IoT</Th>
<Th>Akurasi %</Th>
</tr>
</thead>
<tbody>
{payload.counting.map((row) => (
<tr key={`c-${row.day}`} className="border-t border-slate-100" data-pdf-block>
<Td>{formatDateId(row.date)}</Td>
<Td>{row.day}</Td>
<Td>{cell(row.countBefore)}</Td>
<Td>{cell(row.mortality)}</Td>
<Td>{cell(row.harvest)}</Td>
<Td>{cell(row.harvestWeight)}</Td>
<Td>{cell(row.countAfter)}</Td>
<Td>{cell(row.iotCount)}</Td>
<Td>{accuracyCell(row.accuracy)}</Td>
</tr>
))}
</tbody>
{countingTotal && (
<tfoot>
<TotalRow>
<Td>{countingTotal.label}</Td>
<Td>—</Td>
<Td>{dashCell(countingTotal.countBefore)}</Td>
<Td>{cell(countingTotal.mortality)}</Td>
<Td>{cell(countingTotal.harvest)}</Td>
<Td>{cell(countingTotal.harvestWeight)}</Td>
<Td>{dashCell(countingTotal.countAfter)}</Td>
<Td>{dashCell(countingTotal.iotCount)}</Td>
<Td>
{countingTotal.accuracy == null
? '-'
: accuracyCell(countingTotal.accuracy)}
</Td>
</TotalRow>
</tfoot>
)}
</ReportTable>
<ReportTable title="Berat Ayam harian" divided>
<thead>
<tr>
<Th>Tanggal</Th>
<Th>Umur (hari)</Th>
<Th>Rata-rata manual</Th>
<Th>Rata-rata IoT</Th>
<Th>Keseragaman</Th>
<Th>Selisih</Th>
<Th>Akurasi %</Th>
</tr>
</thead>
<tbody>
{payload.weight.map((row) => (
<tr key={`w-${row.day}`} className="border-t border-slate-100" data-pdf-block>
<Td>{formatDateId(row.date)}</Td>
<Td>{row.day}</Td>
<Td>{cell(row.avgManual)}</Td>
<Td>{cell(row.avgIot)}</Td>
<Td>{accuracyCell(row.uniformity)}</Td>
<Td>{cell(row.difference)}</Td>
<Td>{accuracyCell(row.accuracy)}</Td>
</tr>
))}
</tbody>
</ReportTable>
<ReportTable title="Karung masuk" divided>
<thead>
<tr>
<Th>Tanggal</Th>
<Th>Manual masuk</Th>
<Th>IoT masuk</Th>
<Th>Akurasi %</Th>
</tr>
</thead>
<tbody>
{payload.feedIn.map((row) => (
<tr
key={`in-${row.date}`}
className="border-t border-slate-100"
data-pdf-block
>
<Td>{formatDateId(row.date)}</Td>
<Td>{cell(row.manualIn)}</Td>
<Td>{cell(row.iotIn)}</Td>
<Td>{accuracyCell(row.accuracy)}</Td>
</tr>
))}
</tbody>
{feedInTotal && (
<tfoot>
<TotalRow>
<Td>{feedInTotal.label}</Td>
<Td>{cell(feedInTotal.manualIn)}</Td>
<Td>{cell(feedInTotal.iotIn)}</Td>
<Td>{accuracyCell(feedInTotal.accuracy)}</Td>
</TotalRow>
</tfoot>
)}
</ReportTable>
<ReportTable title="Karung dituang" divided>
<thead>
<tr>
<Th>Tanggal</Th>
<Th>Umur (hari)</Th>
<Th>Manual dituang</Th>
<Th>IoT dituang</Th>
<Th>Akurasi %</Th>
</tr>
</thead>
<tbody>
{payload.feedUse.map((row) => (
<tr
key={`use-${row.day}-${row.date}`}
className="border-t border-slate-100"
data-pdf-block
>
<Td>{formatDateId(row.date)}</Td>
<Td>{row.day}</Td>
<Td>{cell(row.manualUse)}</Td>
<Td>{cell(row.iotUse)}</Td>
<Td>{accuracyCell(row.accuracy)}</Td>
</tr>
))}
</tbody>
{feedUseTotal && (
<tfoot>
<TotalRow>
<Td>{feedUseTotal.label}</Td>
<Td>—</Td>
<Td>{cell(feedUseTotal.manualUse)}</Td>
<Td>{cell(feedUseTotal.iotUse)}</Td>
<Td>{accuracyCell(feedUseTotal.accuracy)}</Td>
</TotalRow>
</tfoot>
)}
</ReportTable>
<ReportTable title="Karung keluar" divided>
<thead>
<tr>
<Th>Tanggal</Th>
<Th>Manual keluar</Th>
<Th>IoT keluar</Th>
<Th>Akurasi %</Th>
</tr>
</thead>
<tbody>
{payload.feedOut.map((row) => (
<tr
key={`out-${row.date}`}
className="border-t border-slate-100"
data-pdf-block
>
<Td>{formatDateId(row.date)}</Td>
<Td>{cell(row.manualOut)}</Td>
<Td>{cell(row.iotOut)}</Td>
<Td>{accuracyCell(row.accuracy)}</Td>
</tr>
))}
</tbody>
{feedOutTotal && (
<tfoot>
<TotalRow>
<Td>{feedOutTotal.label}</Td>
<Td>{cell(feedOutTotal.manualOut)}</Td>
<Td>{cell(feedOutTotal.iotOut)}</Td>
<Td>{accuracyCell(feedOutTotal.accuracy)}</Td>
</TotalRow>
</tfoot>
)}
</ReportTable>
{isEndCycle && (
<>
<ReportTable title="Data Pendukung FCR" divided>
<thead>
<tr>
<Th>Umur</Th>
<Th>Total Pakan</Th>
<Th>Bobot IoT</Th>
<Th>Ayam Hidup</Th>
<Th>Total Bobot Panen</Th>
<Th>FCR</Th>
</tr>
</thead>
<tbody>
{payload.fcr.map((row) => (
<tr
key={`fcr-${row.day}`}
className="border-t border-slate-100"
data-pdf-block
>
<Td>Hari {row.day}</Td>
<Td>{qtyCell(row.feedTotal, 0, ' karung')}</Td>
<Td>{qtyCell(row.iotWeight, 2, row.iotWeight != null ? ' g' : '')}</Td>
<Td>{qtyCell(row.chickenLife, 0, ' ekor')}</Td>
<Td>{harvestKgCell(row.harvestWeightTotal)}</Td>
<Td>{ratioCell(row.iotFcr)}</Td>
</tr>
))}
</tbody>
</ReportTable>
<ReportTable title="Data Pendukung EEF" divided>
<thead>
<tr>
<Th>Umur</Th>
<Th>Persentase Ayam Hidup</Th>
<Th>BW Rata-rata Panen</Th>
<Th>Umur Rata-rata Panen</Th>
<Th>FCR</Th>
<Th>EEF</Th>
</tr>
</thead>
<tbody>
{payload.eef.map((row) => (
<tr
key={`eef-${row.day}`}
className="border-t border-slate-100"
data-pdf-block
>
<Td>Hari {row.day}</Td>
<Td>{qtyCell(row.chickenLifePercentage, 3, '%')}</Td>
<Td>
{qtyCell(
row.iotAverageHarvestBw,
2,
row.iotAverageHarvestBw != null ? ' g' : ''
)}
</Td>
<Td>{qtyCell(row.iotAverageDayWeight, 2)}</Td>
<Td>{ratioCell(row.iotFcr)}</Td>
<Td>{ratioCell(row.iotEef)}</Td>
</tr>
))}
</tbody>
</ReportTable>
</>
)}
</>
)}
</div>
</div>
</div>
</div>
);
};
const ReportTable: React.FC<{
title: string;
children: React.ReactNode;
divided?: boolean;
}> = ({ title, children, divided = false }) => (
<section
data-pdf-block="true"
className={divided ? 'mt-10 border-t-2 border-slate-400 pt-10' : undefined}
>
<h3 className="mb-2 text-sm font-semibold text-slate-800">{title}</h3>
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="min-w-full text-left text-xs text-slate-700">{children}</table>
</div>
</section>
);
const Th: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<th className="bg-slate-50 px-3 py-2 font-semibold text-slate-600">{children}</th>
);
const Td: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<td className="px-3 py-2 tabular-nums">{children}</td>
);
const TotalRow: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<tr className="border-t-2 border-slate-200 bg-slate-50 font-semibold" data-pdf-block>
{children}
</tr>
);
export default ReportPreviewOverlay;
@@ -0,0 +1,81 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import DashboardManualInputForm from '../DashboardManualInputForm.tsx';
const { createMock, listMock } = vi.hoisted(() => ({
createMock: vi.fn(),
listMock: vi.fn(),
}));
vi.mock('../../../services/apiClient.ts', () => ({
api: {
manualInputs: {
list: listMock,
create: createMock,
},
},
ApiError: class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
errorMessage: (err: unknown) => (err instanceof Error ? err.message : 'Terjadi kesalahan'),
}));
describe('DashboardManualInputForm', () => {
beforeEach(() => {
listMock.mockReset();
createMock.mockReset();
listMock.mockResolvedValue([]);
createMock.mockResolvedValue({
id: 1,
cycle: 4,
date: '2026-08-19',
age_manual: 10,
mortality_manual: 5,
mortality_manual_total: 5,
feed_in_manual: 8,
feed_use_manual: 12,
feed_in_manual_total: 8,
feed_use_manual_total: 12,
harvest_manual: 0,
harvest_manual_total: 0,
harvest_weight_manual: 0,
harvest_weight_manual_total: 0,
manual_weight: 320,
});
});
it('lets the user save daily manual fields without totals, FCR, or EEF', async () => {
render(<DashboardManualInputForm cycleId={4} currentDay={10} />);
expect(await screen.findByRole('heading', { name: /Input manual/i })).toBeInTheDocument();
expect(screen.queryByLabelText(/FCR/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/EEF/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/rata-rata panen/i)).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/Umur/i), { target: { value: '10' } });
fireEvent.change(screen.getByLabelText(/Mortalitas/i), { target: { value: '5' } });
fireEvent.change(screen.getByLabelText(/Karung masuk/i), { target: { value: '8' } });
fireEvent.change(screen.getByLabelText(/Karung tuang/i), { target: { value: '12' } });
fireEvent.change(screen.getByLabelText(/Berat panen/i), { target: { value: '8.5' } });
expect(screen.getByLabelText(/Berat panen/i)).toHaveAttribute('step', '0.01');
fireEvent.click(screen.getByRole('button', { name: /Simpan/i }));
await waitFor(() => expect(createMock).toHaveBeenCalledTimes(1));
const body = createMock.mock.calls[0]![0];
expect(body).toMatchObject({
cycle: 4,
age_manual: 10,
mortality_manual: 5,
feed_in_manual: 8,
feed_use_manual: 12,
harvest_weight_manual: 8500,
});
expect(body).not.toHaveProperty('mortality_manual_total');
expect(body).not.toHaveProperty('fcr_manual');
expect(body).not.toHaveProperty('eef_manual');
});
});
@@ -0,0 +1,21 @@
import { fireEvent, render, screen } from '@testing-library/react';
import DashboardReportCtas from '../DashboardReportCtas.tsx';
describe('DashboardReportCtas', () => {
it('disables end-cycle until the cycle is closed', () => {
const onOpenDaily = vi.fn();
const onOpenEndCycle = vi.fn();
render(
<DashboardReportCtas
cycleClosed={false}
hasDailySnapshot={false}
hasEndCycleSnapshot={false}
onOpenDaily={onOpenDaily}
onOpenEndCycle={onOpenEndCycle}
/>
);
expect(screen.getByRole('button', { name: /Akhir Siklus/i })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: /Harian/i }));
expect(onOpenDaily).toHaveBeenCalled();
});
});
@@ -0,0 +1,265 @@
import React, { useMemo, useRef, useState } from 'react';
import type { Karung, ManualInput } from '../../types/api.ts';
import { formatNumber } from '../../utils/format.ts';
type SortOrder = 'asc' | 'desc';
interface FeedDailySummaryTableProps {
rows: Karung[];
manualRows: ManualInput[];
cycleStartDate: string;
currentDay: number;
}
const accuracyOf = (manual: number, iot: number): string => {
if (manual === 0 && iot === 0) return '100.0%';
if (manual === 0 || iot === 0) return '0.0%';
const smaller = Math.min(manual, iot);
const larger = Math.max(manual, iot);
return `${((smaller / larger) * 100).toFixed(1)}%`;
};
const accuracyClass = (manual: number, iot: number): string => {
if (manual === 0 && iot === 0) return 'text-green-600';
if (manual === 0 || iot === 0) return 'text-red-600';
const percentage = (Math.min(manual, iot) / Math.max(manual, iot)) * 100;
if (percentage >= 95) return 'text-green-600';
if (percentage >= 80) return 'text-yellow-600';
return 'text-red-600';
};
const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
rows,
manualRows,
cycleStartDate,
currentDay,
}) => {
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const masukRef = useRef<HTMLDivElement>(null);
const tuangRef = useRef<HTMLDivElement>(null);
const keluarRef = useRef<HTMLDivElement>(null);
const scrollToSection = (ref: React.RefObject<HTMLDivElement | null>) => {
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const aggregatedData = useMemo(() => {
const cycleStart = new Date(`${cycleStartDate}T00:00:00`);
const iotByDate = new Map<string, Karung>();
for (const row of rows) iotByDate.set(row.date, row);
const manualByDate = new Map<string, ManualInput>();
for (const row of manualRows) manualByDate.set(row.date, row);
const result: {
day: number;
date: Date;
dateFormatted: string;
iot: Karung | null;
manual: ManualInput | null;
}[] = [];
for (let d = 0; d <= currentDay; d++) {
const date = new Date(cycleStart);
date.setDate(cycleStart.getDate() + d);
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
date.getDate()
).padStart(2, '0')}`;
result.push({
day: d,
date,
dateFormatted: date.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
}),
iot: iotByDate.get(key) ?? null,
manual: manualByDate.get(key) ?? null,
});
}
return result;
}, [rows, manualRows, cycleStartDate, currentDay]);
const sortedData = useMemo(() => {
const sorted = [...aggregatedData].sort((a, b) => a.day - b.day);
return sortOrder === 'desc' ? sorted.reverse() : sorted;
}, [aggregatedData, sortOrder]);
if (aggregatedData.length === 0 || (rows.length === 0 && manualRows.length === 0)) {
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h2 className="text-xl font-bold mb-4">Rincian Harian Karung IOT</h2>
<div className="h-60 flex items-center justify-center">
<div className="text-center text-gray-500">
<i className="fa-solid fa-table text-5xl text-gray-300 mb-3"></i>
<p className="font-medium text-gray-600">Tidak ada data</p>
<p className="text-sm text-gray-500 mt-1">
Belum ada data karung IOT untuk siklus ini. Sinkronkan lewat tombol Sync hari ini.
</p>
</div>
</div>
</div>
);
}
const renderBox = (
title: string,
emoji: string,
headerColor: string,
manualGetter: (row: ManualInput) => number,
iotGetter: (row: Karung) => number,
totalGetter: (row: Karung) => number,
ref: React.RefObject<HTMLDivElement | null>
) => (
<div className="mb-6" ref={ref} style={{ scrollMarginTop: '100px' }}>
{/* Header */}
<div
className="px-6 py-3 rounded-t-xl font-bold text-black text-lg flex items-center gap-2"
style={{ backgroundColor: headerColor }}
>
<span>{emoji}</span>
<span>{title}</span>
</div>
{/* Table container with scroll */}
<div className="bg-white rounded-b-xl shadow-sm overflow-hidden">
<div className="overflow-x-auto" style={{ maxHeight: '600px', overflowY: 'auto' }}>
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0 z-10">
<tr>
<th className="px-4 py-3 text-left font-semibold text-gray-700">Hari</th>
<th className="px-4 py-3 text-left font-semibold text-gray-700">Tanggal</th>
<th className="px-4 py-2 text-center font-semibold bg-blue-50 text-gray-700">
Manual
</th>
<th className="px-4 py-2 text-center font-semibold bg-gray-100 text-gray-700">
IOT
</th>
<th className="px-4 py-2 text-center font-semibold bg-purple-50 text-gray-700">
Akurasi
</th>
<th className="px-4 py-3 text-center font-semibold text-gray-700">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sortedData.map((summary) => {
const manualValue = summary.manual ? manualGetter(summary.manual) : 0;
const iotValue = summary.iot ? iotGetter(summary.iot) : 0;
const total = summary.iot ? totalGetter(summary.iot) : null;
return (
<tr key={summary.day} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-800 font-bold text-xs">
{summary.day}
</span>
</td>
<td className="px-4 py-3 text-gray-700">{summary.dateFormatted}</td>
<td className="px-4 py-3 text-center font-semibold text-blue-700">
{formatNumber(manualValue)}
</td>
<td className="px-4 py-3 text-center text-gray-700">
{formatNumber(iotValue)}
</td>
<td
className={`px-4 py-3 text-center text-xs font-medium ${accuracyClass(
manualValue,
iotValue
)}`}
>
{accuracyOf(manualValue, iotValue)}
</td>
<td className="px-4 py-3 text-center text-gray-700">
{total == null ? '-' : formatNumber(total)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
return (
<div className="bg-gray-50 p-6 rounded-xl">
{/* Header */}
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-800">Rincian Harian Karung IOT</h2>
<button
onClick={() => setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'))}
className="flex items-center gap-2 px-4 py-2 bg-white hover:bg-gray-100 rounded-lg text-sm font-medium shadow-sm transition-colors"
>
<i
className={`fa-solid ${
sortOrder === 'asc' ? 'fa-arrow-up-short-wide' : 'fa-arrow-down-short-wide'
}`}
></i>
<span>{sortOrder === 'asc' ? 'Terlama' : 'Terbaru'}</span>
</button>
</div>
{/* Sticky Quick Navigation Bar (Excel-style freeze) */}
<div className="sticky top-0 z-20 -mx-6 mb-6 bg-white border-y border-gray-200 shadow-md">
<div className="px-6 py-4">
<div className="flex gap-2 justify-center">
<button
onClick={() => scrollToSection(masukRef)}
className="flex-1 max-w-xs flex items-center justify-center gap-2 px-6 py-3 bg-green-50 hover:bg-green-100 border-2 border-green-300 text-green-900 rounded-lg font-semibold shadow-sm transition-all hover:shadow-md active:scale-95"
>
<span className="text-xl">🟢</span>
<span>Karung Masuk</span>
<i className="fa-solid fa-chevron-down text-xs ml-1"></i>
</button>
<button
onClick={() => scrollToSection(tuangRef)}
className="flex-1 max-w-xs flex items-center justify-center gap-2 px-6 py-3 bg-orange-50 hover:bg-orange-100 border-2 border-orange-300 text-orange-900 rounded-lg font-semibold shadow-sm transition-all hover:shadow-md active:scale-95"
>
<span className="text-xl">🟠</span>
<span>Karung Tuang</span>
<i className="fa-solid fa-chevron-down text-xs ml-1"></i>
</button>
<button
onClick={() => scrollToSection(keluarRef)}
className="flex-1 max-w-xs flex items-center justify-center gap-2 px-6 py-3 bg-red-50 hover:bg-red-100 border-2 border-red-300 text-red-900 rounded-lg font-semibold shadow-sm transition-all hover:shadow-md active:scale-95"
>
<span className="text-xl">🔴</span>
<span>Karung Keluar</span>
<i className="fa-solid fa-chevron-down text-xs ml-1"></i>
</button>
</div>
</div>
</div>
{/* Three separate boxes */}
{renderBox(
'KARUNG MASUK',
'🟢',
'#86efac',
(row) => row.feed_in_manual,
(row) => row.in_today,
(row) => row.in_total,
masukRef
)}
{renderBox(
'KARUNG TUANG',
'🟠',
'#fdba74',
(row) => row.feed_use_manual,
(row) => row.feed_use_today,
(row) => row.feed_use_total,
tuangRef
)}
{renderBox(
'KARUNG KELUAR',
'🔴',
'#fca5a5',
(row) => row.feed_out_manual,
(row) => row.out_today,
(row) => row.out_total,
keluarRef
)}
</div>
);
};
export default FeedDailySummaryTable;
+114
View File
@@ -0,0 +1,114 @@
import React, { useMemo } from 'react';
import type { Karung } from '../../types/api.ts';
import { formatNumber } from '../../utils/format.ts';
interface FeedSaldoCardProps {
latest: Karung | null;
cycleStartDate: string;
displayDate: string;
}
const FeedSaldoCard: React.FC<FeedSaldoCardProps> = ({ latest, cycleStartDate, displayDate }) => {
const saldoData = useMemo(() => {
const inbound = latest?.in_total ?? 0;
const feedUse = latest?.feed_use_total ?? 0;
const keluar = latest?.out_total ?? 0;
const saldo = inbound - feedUse - keluar;
return { inbound, feedUse, keluar, saldo };
}, [latest]);
const formatDateRange = useMemo(() => {
const formatDate = (iso: string) =>
new Intl.DateTimeFormat('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(new Date(`${iso}T00:00:00`));
return `${formatDate(cycleStartDate)} - ${formatDate(displayDate)}`;
}, [cycleStartDate, displayDate]);
const getSaldoStatusColor = (saldo: number) => {
if (saldo > 0) return 'text-green-600';
if (saldo < 0) return 'text-red-600';
return 'text-gray-600';
};
return (
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-6 rounded-xl shadow-sm border-2 border-blue-200">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="bg-blue-500 text-white p-3 rounded-lg">
<i className="fa-solid fa-scale-balanced text-2xl"></i>
</div>
<div>
<h2 className="text-xl font-bold text-gray-800">Saldo Karung IOT</h2>
<p className="text-sm text-gray-600">{formatDateRange}</p>
</div>
</div>
</div>
{/* Saldo Display */}
<div className="bg-white rounded-lg p-6 border border-blue-200">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{/* Karung Masuk */}
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<i className="fa-solid fa-arrow-down text-green-600"></i>
<p className="text-sm font-semibold text-gray-700">Karung Masuk</p>
</div>
<p className="text-3xl font-bold text-green-600">{formatNumber(saldoData.inbound)}</p>
<p className="text-xs text-gray-400 mt-1">karung</p>
</div>
{/* Karung Dituang (feed use) */}
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<i className="fa-solid fa-arrow-up text-orange-600"></i>
<p className="text-sm font-semibold text-gray-700">Karung Dituang</p>
</div>
<p className="text-3xl font-bold text-orange-600">{formatNumber(saldoData.feedUse)}</p>
<p className="text-xs text-gray-400 mt-1">karung</p>
</div>
{/* Karung Keluar */}
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<i className="fa-solid fa-arrow-right text-red-600"></i>
<p className="text-sm font-semibold text-gray-700">Karung Keluar</p>
</div>
<p className="text-3xl font-bold text-red-600">{formatNumber(saldoData.keluar)}</p>
<p className="text-xs text-gray-400 mt-1">karung</p>
</div>
{/* Saldo */}
<div className="text-center lg:border-l-2 lg:border-blue-200 lg:pl-6">
<div className="flex items-center justify-center gap-2 mb-2">
<i className="fa-solid fa-scale-balanced text-blue-600"></i>
<p className="text-sm font-semibold text-gray-700">Saldo</p>
</div>
<p className={`text-4xl font-bold ${getSaldoStatusColor(saldoData.saldo)}`}>
{formatNumber(saldoData.saldo)}
</p>
<p className="text-xs text-gray-400 mt-1">karung</p>
</div>
</div>
<p className="text-xs text-gray-400 mt-4 text-center">
Saldo = karung masuk − karung dituang − karung keluar.
</p>
</div>
{/* Warning for negative saldo */}
{saldoData.saldo < 0 && (
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-3 flex items-start gap-2">
<i className="fa-solid fa-triangle-exclamation text-yellow-600 mt-0.5"></i>
<p className="text-xs text-yellow-800">
<strong>Peringatan:</strong> Saldo negatif menunjukkan karung dituang lebih banyak dari
karung masuk. Periksa kembali data sinkronisasi karung-web-admin.
</p>
</div>
)}
</div>
);
};
export default FeedSaldoCard;
+145
View File
@@ -0,0 +1,145 @@
import React, { useMemo } from 'react';
import type { Karung, ManualInput } from '../../types/api.ts';
import { formatNumber } from '../../utils/format.ts';
const StatItem: React.FC<{
icon: React.ReactNode;
label: string;
value: number | null;
colorOverride?: string;
}> = ({ icon, label, value, colorOverride }) => (
<div className="border border-gray-200 rounded-lg p-3 hover:border-gray-300 transition-colors">
<div className="flex items-start gap-2 mb-3 min-w-0">
<div className="text-gray-500">{icon}</div>
<p className="min-w-0 truncate text-xs sm:text-sm font-medium text-gray-700" title={label}>
{label}
</p>
</div>
<div className="rounded-md bg-gray-50/70 p-3">
<p
className="text-xl sm:text-2xl font-bold leading-none"
style={{ color: colorOverride ?? '#374151' }}
>
{value == null ? 'N/A' : formatNumber(value)}
</p>
<p className="text-xs text-gray-400 mt-0.5">karung</p>
</div>
</div>
);
interface FeedStatsProps {
latest: Karung | null;
latestManual: ManualInput | null;
cycleStartDate: string;
displayDate: string;
}
const FeedStats: React.FC<FeedStatsProps> = ({
latest,
latestManual,
cycleStartDate,
displayDate,
}) => {
const formatDisplayDate = (iso: string) =>
new Intl.DateTimeFormat('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(new Date(`${iso}T00:00:00`));
const todayLabel = useMemo(() => formatDisplayDate(displayDate), [displayDate]);
const totalCycleLabel = useMemo(() => {
const start = new Intl.DateTimeFormat('id-ID', {
day: 'numeric',
month: 'short',
}).format(new Date(`${cycleStartDate}T00:00:00`));
const end = new Intl.DateTimeFormat('id-ID', {
day: 'numeric',
month: 'short',
}).format(new Date(`${displayDate}T00:00:00`));
return `Total (${start} - ${end})`;
}, [cycleStartDate, displayDate]);
const columns = useMemo(
() => [
{
key: 'masuk',
title: 'Karung Masuk',
color: '#2563eb',
icon: <i className="fa-solid fa-arrow-down text-blue-600"></i>,
iotToday: latest?.in_today ?? null,
iotTotal: latest?.in_total ?? null,
manualToday: latestManual?.feed_in_manual ?? null,
manualTotal: latestManual?.feed_in_manual_total ?? null,
},
{
key: 'dituang',
title: 'Karung Dituang',
color: '#ea580c',
icon: <i className="fa-solid fa-arrow-up text-orange-600"></i>,
iotToday: latest?.feed_use_today ?? null,
iotTotal: latest?.feed_use_total ?? null,
manualToday: latestManual?.feed_use_manual ?? null,
manualTotal: latestManual?.feed_use_manual_total ?? null,
},
{
key: 'keluar',
title: 'Karung Keluar',
color: '#dc2626',
icon: <i className="fa-solid fa-arrow-right text-red-600"></i>,
iotToday: latest?.out_today ?? null,
iotTotal: latest?.out_total ?? null,
manualToday: latestManual?.feed_out_manual ?? null,
manualTotal: latestManual?.feed_out_manual_total ?? null,
},
],
[latest, latestManual]
);
return (
<div className="bg-white p-4 sm:p-6 rounded-xl shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
{columns.map((col) => (
<div key={col.key}>
<h3 className="font-semibold text-sm sm:text-base mb-2" style={{ color: col.color }}>
{col.title}
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-3">
<StatItem
icon={<i className="fa-solid fa-microchip text-purple-600"></i>}
label={`IOT · ${todayLabel}`}
value={col.iotToday}
colorOverride={col.color}
/>
<StatItem
icon={<i className="fa-solid fa-microchip text-purple-600"></i>}
label={`IOT · ${totalCycleLabel}`}
value={col.iotTotal}
colorOverride={col.color}
/>
</div>
<div className="space-y-3">
<StatItem
icon={<i className="fa-solid fa-user-pen text-gray-500"></i>}
label={`Manual · ${todayLabel}`}
value={col.manualToday}
colorOverride="#6b7280"
/>
<StatItem
icon={<i className="fa-solid fa-user-pen text-gray-500"></i>}
label={`Manual · ${totalCycleLabel}`}
value={col.manualTotal}
colorOverride="#6b7280"
/>
</div>
</div>
</div>
))}
</div>
</div>
);
};
export default FeedStats;
+100
View File
@@ -0,0 +1,100 @@
import React, { useMemo } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import type { Karung } from '../../types/api.ts';
interface FeedTrendChartProps {
rows: Karung[];
cycleStartDate: string;
currentDay: number;
}
const FeedTrendChart: React.FC<FeedTrendChartProps> = ({ rows, cycleStartDate, currentDay }) => {
const chartData = useMemo(() => {
const cycleStart = new Date(`${cycleStartDate}T00:00:00`);
const dailyData: { label: string; masuk: number; dituang: number }[] = [];
for (let d = 0; d <= currentDay; d++) {
const date = new Date(cycleStart);
date.setDate(cycleStart.getDate() + d);
dailyData.push({
label: date.toLocaleDateString('id-ID', { day: '2-digit', month: 'short' }),
masuk: 0,
dituang: 0,
});
}
const byKey = new Map<string, { masuk: number; dituang: number }>();
for (const row of rows) {
const bucket = byKey.get(row.date) ?? { masuk: 0, dituang: 0 };
bucket.masuk += row.in_today;
bucket.dituang += row.out_today;
byKey.set(row.date, bucket);
}
for (let d = 0; d <= currentDay; d++) {
const date = new Date(cycleStart);
date.setDate(cycleStart.getDate() + d);
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
date.getDate()
).padStart(2, '0')}`;
const bucket = byKey.get(key);
const entry = dailyData[d];
if (bucket && entry) {
entry.masuk = bucket.masuk;
entry.dituang = bucket.dituang;
}
}
return dailyData;
}, [rows, cycleStartDate, currentDay]);
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<h2 className="text-xl font-bold text-gray-800 mb-4">Tren Harian Karung IOT</h2>
<div className="h-60">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 5, right: 20, left: -15, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis
dataKey="label"
fontSize={11}
tick={{ fill: '#6b7280' }}
angle={-45}
textAnchor="end"
height={70}
/>
<YAxis fontSize={12} tick={{ fill: '#6b7280' }} allowDecimals={false} />
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.9)',
border: '1px solid #ccc',
borderRadius: '8px',
}}
formatter={(value: unknown, name: unknown) => [
`${Number(value ?? 0)} karung`,
name === 'masuk' ? 'Karung Masuk' : 'Karung Dituang',
]}
labelFormatter={(label) => `Tanggal: ${label}`}
/>
<Legend
formatter={(value) => (value === 'masuk' ? 'Karung Masuk' : 'Karung Dituang')}
/>
<Bar dataKey="masuk" name="masuk" fill="#3b82f6" barSize={8} />
<Bar dataKey="dituang" name="dituang" fill="#dc2626" barSize={8} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default FeedTrendChart;
+164
View File
@@ -0,0 +1,164 @@
import React, { useMemo, useState } from 'react';
import { api, errorMessage } from '../../services/apiClient.ts';
import type { Cycle, InitialBalanceResult } from '../../types/api.ts';
import { formatNumber } from '../../utils/format.ts';
interface InitialBalanceCardProps {
cycle: Cycle;
onSaved: () => void;
}
const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle, onSaved }) => {
const [balanceDate, setBalanceDate] = useState(cycle.start_date);
const [manualEntry, setManualEntry] = useState<string>(
cycle.feed_initial_balance > 0 ? String(cycle.feed_initial_balance) : ''
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<InitialBalanceResult | null>(null);
const canSave = useMemo(
() => balanceDate !== '' && manualEntry !== '' && Number(manualEntry) >= 0 && !saving,
[balanceDate, manualEntry, saving]
);
const handleSave = async () => {
if (!canSave) return;
setSaving(true);
setError(null);
try {
const saved = await api.cycles.initialBalance(cycle.id, {
date: balanceDate,
feed_in_manual: Number(manualEntry),
});
setResult(saved);
onSaved();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const accuracy = result?.accuracy;
const accuracyLabel = accuracy == null ? 'N/A' : `${formatNumber(accuracy, 1)}%`;
const accuracyColor =
accuracy == null
? 'text-gray-500'
: accuracy >= 95
? 'text-green-600'
: accuracy >= 80
? 'text-yellow-600'
: 'text-red-600';
return (
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-6 rounded-xl shadow-sm border-2 border-blue-200">
<div className="flex items-center gap-3 mb-4">
<div className="bg-blue-500 text-white p-3 rounded-lg">
<i className="fa-solid fa-boxes-packing text-2xl"></i>
</div>
<div>
<h2 className="text-xl font-bold text-gray-800">Saldo Awal</h2>
<p className="text-sm text-gray-600">
Masukkan saldo awal karung pakan pada tanggal tertentu, lalu bandingkan dengan deteksi
IoT.
</p>
</div>
</div>
<div className="bg-white rounded-lg p-6 border border-blue-200">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label
htmlFor="initial-balance-date"
className="text-sm font-semibold text-gray-700 block mb-1"
>
Tanggal
</label>
<input
id="initial-balance-date"
type="date"
value={balanceDate}
onChange={(event) => setBalanceDate(event.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
</div>
<div>
<label
htmlFor="initial-balance-manual"
className="text-sm font-semibold text-gray-700 block mb-1"
>
Feed In Manual (karung)
</label>
<input
id="initial-balance-manual"
type="number"
min={0}
value={manualEntry}
onChange={(event) => setManualEntry(event.target.value)}
placeholder="Contoh: 120"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
</div>
</div>
{error && (
<p className="mt-3 text-sm font-semibold text-red-700 bg-red-50 border border-red-200 rounded-lg p-3">
{error}
</p>
)}
<div className="mt-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<button
type="button"
onClick={handleSave}
disabled={!canSave}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
<i className={`fa-solid fa-floppy-disk ${saving ? 'fa-spin' : ''}`} />
<span>{saving ? 'Menyimpan...' : 'Simpan Saldo Awal'}</span>
</button>
{cycle.feed_initial_balance > 0 && result == null && (
<p className="text-sm text-gray-600">
Saldo awal tersimpan:{' '}
<strong className="text-blue-700">
{formatNumber(cycle.feed_initial_balance)} karung
</strong>
</p>
)}
</div>
{result && (
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50/50 p-4">
<p className="text-sm font-semibold text-gray-800 mb-3">Perbandingan {result.date}</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-center">
<div className="rounded-lg bg-white border border-gray-200 p-3">
<p className="text-xs font-semibold text-blue-700">Manual</p>
<p className="text-2xl font-bold text-gray-800">
{formatNumber(result.manual)} karung
</p>
</div>
<div className="rounded-lg bg-white border border-gray-200 p-3">
<p className="text-xs font-semibold text-purple-700">IOT</p>
<p className="text-2xl font-bold text-gray-800">
{result.iot == null ? 'N/A' : `${formatNumber(result.iot)} karung`}
</p>
</div>
<div className="rounded-lg bg-white border border-gray-200 p-3">
<p className="text-xs font-semibold text-gray-600">Akurasi</p>
<p className={`text-2xl font-bold ${accuracyColor}`}>{accuracyLabel}</p>
</div>
</div>
{result.sync_error && (
<p className="mt-3 text-xs text-yellow-700 bg-yellow-50 border border-yellow-200 rounded-lg p-2">
Gagal menarik data IoT dari karung-web-admin: {result.sync_error}
</p>
)}
</div>
)}
</div>
</div>
);
};
export default InitialBalanceCard;
@@ -0,0 +1,131 @@
import React from 'react';
import {
CartesianGrid,
Line,
LineChart,
ReferenceDot,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import {
CHICKEN_CHILL_FACTOR_POINTS,
chickenChillFactorFromAge,
formatChillFactor,
} from './chillFactor.ts';
const CHILL_FACTOR_BASE_MAX_DAY =
CHICKEN_CHILL_FACTOR_POINTS[CHICKEN_CHILL_FACTOR_POINTS.length - 1]!.age;
export interface IotPanelChillFactorCardProps {
dayAge: number | null | undefined;
chillFactor: number | null | undefined;
}
const IotPanelChillFactorCard: React.FC<IotPanelChillFactorCardProps> = ({
dayAge,
chillFactor,
}) => {
const dayAgeValue =
dayAge != null && Number.isFinite(Number(dayAge)) ? Math.max(0, Number(dayAge)) : null;
const chartMinDay = CHICKEN_CHILL_FACTOR_POINTS[0]!.age;
const chartMaxDay = Math.max(CHILL_FACTOR_BASE_MAX_DAY, Math.ceil(dayAgeValue ?? 0));
const chillFactorChartData = Array.from({ length: chartMaxDay - chartMinDay + 1 }, (_, idx) => {
const day = chartMinDay + idx;
return { day, factor: chickenChillFactorFromAge(day) ?? 0 };
});
const chillFactorXAxisTicks = Array.from(new Set([0, 7, 14, 21, 28, 35, 42, chartMaxDay])).filter(
(day) => day <= chartMaxDay
);
const currentChillPoint =
dayAgeValue !== null && chillFactor != null && Number.isFinite(Number(chillFactor))
? { day: dayAgeValue, factor: Number(chillFactor) }
: null;
const chillFactorChartLabel = currentChillPoint
? `Grafik chill factor. Umur ayam hari ${currentChillPoint.day}, chill factor ${formatChillFactor(currentChillPoint.factor)}.`
: 'Grafik chill factor belum tersedia karena umur ayam tidak tersedia.';
return (
<article className="rounded-xl border border-slate-200 bg-gradient-to-br from-sky-50 via-cyan-50 to-blue-50 p-4 shadow-sm">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h2 className="font-semibold text-slate-800">Grafik Chill Factor</h2>
<p className="mt-1 text-xs text-slate-500">
Grafik berdasarkan umur ayam untuk estimasi efek angin pada suhu terasa.
</p>
</div>
<span className="rounded-full bg-white px-3 py-1 text-lg font-bold text-sky-700 shadow-sm">
{formatChillFactor(chillFactor)}
</span>
</div>
<div
className="h-56 rounded-lg border border-sky-100 bg-white/75 p-2"
role="img"
aria-label={chillFactorChartLabel}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chillFactorChartData}
margin={{ top: 12, right: 16, left: -18, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
dataKey="day"
type="number"
domain={[chartMinDay, chartMaxDay]}
ticks={chillFactorXAxisTicks}
tickFormatter={(value) => `${Number(value)}`}
stroke="#64748b"
fontSize={11}
label={{
value: 'Umur (hari)',
position: 'insideBottom',
offset: -2,
fill: '#64748b',
fontSize: 11,
}}
/>
<YAxis
domain={[3, 8]}
ticks={[3, 3.5, 4.5, 6, 7, 8]}
tickFormatter={(value) => `${Number(value).toString().replace('.', ',')}`}
stroke="#64748b"
fontSize={11}
width={44}
/>
<Tooltip
labelFormatter={(day) => `Hari ${Number(day).toFixed(0)}`}
formatter={(value) => [formatChillFactor(Number(value)), 'Chill factor']}
/>
<Line
type="linear"
dataKey="factor"
name="Chill factor"
stroke="#0284c7"
strokeWidth={3}
dot={{ r: 2 }}
activeDot={{ r: 5 }}
/>
{currentChillPoint && (
<>
<ReferenceLine x={currentChillPoint.day} stroke="#0f766e" strokeDasharray="4 4" />
<ReferenceDot
x={currentChillPoint.day}
y={currentChillPoint.factor}
r={6}
fill="#0f766e"
stroke="#ffffff"
strokeWidth={2}
/>
</>
)}
</LineChart>
</ResponsiveContainer>
</div>
</article>
);
};
export default IotPanelChillFactorCard;
@@ -0,0 +1,97 @@
import React from 'react';
import { formatNumber } from '../../utils/format.ts';
import { formatChillFactor, type ExperienceCalc } from './chillFactor.ts';
const DEGREE_C = '\u00B0C';
const oneDecimal = (value: number | null): string =>
value === null ? '--' : formatNumber(value, 1);
const formatSigned = (value: number | null): string => {
if (value === null) return '--';
const sign = value > 0 ? '+' : '';
return `${sign}${oneDecimal(value)} ${DEGREE_C}`;
};
export interface IotPanelExperienceTempCardProps {
calc: ExperienceCalc;
apiExperienceTemp: number | null | undefined;
}
const IotPanelExperienceTempCard: React.FC<IotPanelExperienceTempCardProps> = ({
calc,
apiExperienceTemp,
}) => {
const calculationRows = [
{
label: 'Kecepatan angin',
value: calc.windSpeed === null ? '--' : `${oneDecimal(calc.windSpeed)} m/s`,
note: 'Dari sensor angin kandang',
},
{
label: 'Chill factor',
value: formatChillFactor(calc.chillFactor),
note: 'Berdasarkan grafik chill factor',
},
{
label: 'Koreksi RH',
value: formatSigned(calc.humidityAdjustment),
note: '(RH - 70) / 5',
},
{
label: 'Suhu rata-rata',
value: calc.averageTemp === null ? '--' : `${oneDecimal(calc.averageTemp)} ${DEGREE_C}`,
note: 'Suhu rata-rata dari Panel',
},
{
label: 'Chill effect',
value: formatSigned(calc.chillEffect),
note: '(Angin x chill factor) - koreksi RH',
},
];
const apiDiffers =
apiExperienceTemp != null &&
calc.experienceTemp != null &&
Math.abs(apiExperienceTemp - calc.experienceTemp) > 0.05;
return (
<article className="rounded-xl border border-sky-200 bg-gradient-to-br from-sky-50 to-blue-50 p-4 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-base font-semibold text-slate-800">
<i className="fa-solid fa-temperature-half mr-2 text-sky-600" />
Experience Suhu
</h2>
<p className="mt-1 text-xs text-slate-500">Suhu yang dirasakan oleh ayam.</p>
</div>
<p className="shrink-0 text-3xl font-bold text-sky-600">
{calc.experienceTemp === null ? 'N/A' : `${oneDecimal(calc.experienceTemp)} ${DEGREE_C}`}
</p>
</div>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
{calculationRows.map((item) => (
<div key={item.label} className="rounded-lg border border-sky-100 bg-white/70 p-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-slate-500">{item.label}</span>
<strong className="text-sm text-slate-800">{item.value}</strong>
</div>
<p className="mt-1 text-[11px] text-slate-400">{item.note}</p>
</div>
))}
</div>
<div className="mt-3 rounded-lg bg-white/60 px-3 py-2 text-xs text-slate-500">
Experience suhu = Suhu rata-rata - chill effect.
{apiDiffers && apiExperienceTemp != null && (
<span className="mt-1 block">
Nilai tercatat Panel: {oneDecimal(apiExperienceTemp)} {DEGREE_C}.
</span>
)}
</div>
</article>
);
};
export default IotPanelExperienceTempCard;
+79
View File
@@ -0,0 +1,79 @@
export const CHICKEN_CHILL_FACTOR_POINTS = [
{ age: 0, factor: 8 },
{ age: 1, factor: 8 },
{ age: 7, factor: 7 },
{ age: 14, factor: 6 },
{ age: 21, factor: 4.5 },
{ age: 28, factor: 3.5 },
{ age: 35, factor: 3.5 },
{ age: 42, factor: 3 },
] as const;
export function chickenChillFactorFromAge(day: number | null | undefined): number | null {
const age = Number(day);
if (!Number.isFinite(age) || age < 0) return null;
const firstPoint = CHICKEN_CHILL_FACTOR_POINTS[0]!;
if (age <= firstPoint.age) return firstPoint.factor;
for (let idx = 1; idx < CHICKEN_CHILL_FACTOR_POINTS.length; idx += 1) {
const previousPoint = CHICKEN_CHILL_FACTOR_POINTS[idx - 1]!;
const nextPoint = CHICKEN_CHILL_FACTOR_POINTS[idx]!;
if (age > nextPoint.age) continue;
const ageRange = nextPoint.age - previousPoint.age;
if (ageRange <= 0) return nextPoint.factor;
const progress = (age - previousPoint.age) / ageRange;
return previousPoint.factor + (nextPoint.factor - previousPoint.factor) * progress;
}
return CHICKEN_CHILL_FACTOR_POINTS[CHICKEN_CHILL_FACTOR_POINTS.length - 1]!.factor;
}
export type ExperienceCalc = {
averageTemp: number | null;
windSpeed: number | null;
humidity: number | null;
humidityAdjustment: number | null;
chillFactor: number | null;
chillEffect: number | null;
experienceTemp: number | null;
};
export function computeExperienceCalc(args: {
averageTemp: number | null | undefined;
windSpeed: number | null | undefined;
humidity: number | null | undefined;
dayAge: number | null | undefined;
}): ExperienceCalc {
const toNumber = (value: number | null | undefined): number | null =>
value != null && Number.isFinite(Number(value)) ? Number(value) : null;
const averageTemp = toNumber(args.averageTemp);
const windSpeed = toNumber(args.windSpeed);
const humidity = toNumber(args.humidity);
const chillFactor = chickenChillFactorFromAge(args.dayAge);
const humidityAdjustment = humidity === null ? null : (humidity - 70) / 5;
const chillEffect =
windSpeed !== null && chillFactor !== null && humidityAdjustment !== null
? windSpeed * chillFactor - humidityAdjustment
: null;
const experienceTemp =
averageTemp !== null && chillEffect !== null ? averageTemp - chillEffect : null;
return {
averageTemp,
windSpeed,
humidity,
humidityAdjustment,
chillFactor,
chillEffect,
experienceTemp,
};
}
export function formatChillFactor(value: number | null | undefined): string {
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '--';
return Number(value).toFixed(3).replace('.', ',').replace(/0+$/, '').replace(/,$/, '');
}
+115
View File
@@ -0,0 +1,115 @@
import React, { useState, useEffect } from 'react';
import { View } from '../../types/common.ts';
export interface NavItem {
view: View;
label: string;
icon: React.ReactNode;
permissionId?: string;
subItems?: NavItem[];
disabled?: boolean;
}
interface NavLinkProps {
item: NavItem;
isActive: boolean;
isCollapsed: boolean;
setView: (view: View) => void;
view: View;
}
const NavLink: React.FC<NavLinkProps> = ({ item, isActive, isCollapsed, setView, view }) => {
const [isOpen, setIsOpen] = useState(isActive);
useEffect(() => {
if (isActive) {
setIsOpen(true);
}
}, [isActive]);
useEffect(() => {
if (isCollapsed) {
setIsOpen(false);
}
}, [isCollapsed]);
if (item.subItems) {
return (
<li className="w-full">
<button
onClick={() => !isCollapsed && setIsOpen(!isOpen)}
title={isCollapsed ? item.label : undefined}
className={`w-full flex items-center h-11 px-4 rounded-lg transition-colors duration-200 group ${
isActive
? 'bg-red-50 text-red-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
>
<div className="w-8 text-lg flex items-center justify-center -ml-1">{item.icon}</div>
{!isCollapsed && (
<span className="ml-2 text-sm font-semibold whitespace-nowrap flex-grow text-left">
{item.label}
</span>
)}
{!isCollapsed && (
<i
className={`fa-solid fa-chevron-right text-xs transition-transform duration-200 ${isOpen ? 'rotate-90' : ''}`}
></i>
)}
</button>
{!isCollapsed && isOpen && (
<ul className="pl-8 mt-1 space-y-1">
{item.subItems.map((subItem) => (
<li key={subItem.view}>
<button
onClick={() => !subItem.disabled && setView(subItem.view)}
disabled={subItem.disabled}
className={`w-full flex items-center h-9 pl-3 pr-2 rounded-md transition-colors duration-200 group text-left ${
subItem.disabled
? 'opacity-40 cursor-not-allowed text-gray-400'
: view === subItem.view
? 'bg-red-600 text-white shadow-sm'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-800'
}`}
>
<div className="w-6 text-base flex items-center justify-center -ml-1">
{subItem.icon}
</div>
<span className="ml-2 text-sm font-medium whitespace-nowrap">
{subItem.label}
</span>
{subItem.disabled && <i className="fa-solid fa-lock ml-auto text-xs"></i>}
</button>
</li>
))}
</ul>
)}
</li>
);
}
return (
<li className="w-full">
<button
onClick={() => !item.disabled && setView(item.view)}
disabled={item.disabled}
title={isCollapsed ? item.label : undefined}
className={`w-full flex items-center h-11 px-4 rounded-lg transition-colors duration-200 group ${
item.disabled
? 'opacity-40 cursor-not-allowed text-gray-400'
: isActive
? 'bg-red-600 text-white shadow-md'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
>
<div className="w-8 text-lg flex items-center justify-center -ml-1">{item.icon}</div>
{!isCollapsed && (
<span className="ml-2 text-sm font-semibold whitespace-nowrap">{item.label}</span>
)}
{!isCollapsed && item.disabled && <i className="fa-solid fa-lock ml-auto text-xs"></i>}
</button>
</li>
);
};
export default NavLink;
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
type BannerProps = {
kind?: 'error' | 'info' | 'success';
children: React.ReactNode;
};
const styles = {
error: 'bg-red-50 text-red-800 border-red-200',
info: 'bg-sky-50 text-sky-800 border-sky-200',
success: 'bg-green-50 text-green-800 border-green-200',
};
export const Banner: React.FC<BannerProps> = ({ kind = 'info', children }) => (
<div className={`rounded-lg border px-4 py-3 text-sm ${styles[kind]}`}>{children}</div>
);
export const PageHeader: React.FC<{
title: string;
subtitle?: string;
icon?: React.ReactNode;
actions?: React.ReactNode;
}> = ({ title, subtitle, icon, actions }) => (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
<div className="flex items-center">
{icon && (
<div className="bg-red-600 text-white p-3 rounded-lg mr-4">
<span className="text-2xl sm:text-3xl">{icon}</span>
</div>
)}
<div>
<h1 className="text-2xl font-bold text-gray-800">{title}</h1>
{subtitle && <p className="text-sm text-gray-500 mt-1">{subtitle}</p>}
</div>
</div>
{actions}
</div>
);
export const Spinner: React.FC = () => (
<div className="flex items-center justify-center py-12 text-gray-400 gap-2">
<i className="fa-solid fa-spinner fa-spin" />
<span>Memuat...</span>
</div>
);
export const EmptyFarm: React.FC<{ onSettings: () => void }> = ({ onSettings }) => (
<Banner>
Pilih site, kandang, dan siklus, atau buat data di{' '}
<button type="button" className="font-semibold underline" onClick={onSettings}>
Pengaturan
</button>
.
</Banner>
);
+103
View File
@@ -0,0 +1,103 @@
import React from 'react';
import type { ChickenWeight } from '../../types/api.ts';
import { formatDateId, formatNumber } from '../../utils/format.ts';
import { formatWeight, getTargetWeightForAge } from '../../utils/weight.ts';
interface DailyDetailPanelProps {
row: ChickenWeight | null;
totalDays: number;
}
const DetailCard: React.FC<{
label: string;
value: string;
icon: React.ReactNode;
highlight?: boolean;
}> = ({ label, value, icon, highlight = false }) => (
<div
className={`bg-white rounded-xl shadow-sm p-4 ${
highlight ? 'border-2 border-red-500' : 'border border-gray-100'
}`}
>
<div className="flex items-center gap-3">
<div className="text-2xl text-red-500 flex-shrink-0">{icon}</div>
<div className="min-w-0">
<p className="text-xs text-gray-500">{label}</p>
<p className="text-xl font-bold text-gray-800 truncate">{value}</p>
</div>
</div>
</div>
);
const DailyDetailPanel: React.FC<DailyDetailPanelProps> = ({ row, totalDays }) => {
if (!row) {
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<div className="flex flex-col items-center justify-center py-6 text-center">
<i className="fa-solid fa-calendar-xmark text-4xl text-gray-300 mb-3"></i>
<p className="text-gray-500 font-medium">Belum ada data untuk tanggal ini</p>
<p className="text-gray-400 text-sm mt-1">
Pilih tanggal lain untuk melihat rincian harian
</p>
</div>
</div>
);
}
const target = getTargetWeightForAge(row.age, totalDays);
const deviation = target != null && row.average_weight > 0 ? row.average_weight - target : null;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
<DetailCard
label="Tanggal"
value={formatDateId(row.date)}
icon={<i className="fa-solid fa-calendar-day"></i>}
/>
<DetailCard
label="Umur"
value={`${row.age} hari`}
icon={<i className="fa-solid fa-clock"></i>}
/>
<DetailCard
label="DOC"
value={formatWeight(row.doc_weight, { decimals: 0 })}
icon={<i className="fa-solid fa-egg"></i>}
/>
<DetailCard
label="AVG Berat"
value={formatWeight(row.average_weight, { stripTrailingZeros: true })}
icon={<i className="fa-solid fa-weight-scale"></i>}
highlight
/>
<DetailCard
label="Uniformity"
value={`${formatNumber(row.uniformity, 2, 2, true)}%`}
icon={<i className="fa-solid fa-percent"></i>}
/>
<DetailCard
label="ADG"
value={`${formatNumber(row.average_daily_gain, 2, 2, true)} g/hari`}
icon={<i className="fa-solid fa-chart-line"></i>}
/>
<DetailCard
label="Populasi"
value={`${formatNumber(row.chicken_count, 0)} ekor`}
icon={<i className="fa-solid fa-users"></i>}
/>
{deviation != null && (
<DetailCard
label="Deviasi vs Target"
value={`${deviation >= 0 ? '+' : ''}${formatWeight(Math.abs(deviation), {
stripTrailingZeros: true,
})}`}
icon={<i className="fa-solid fa-arrow-trend-up"></i>}
/>
)}
</div>
</div>
);
};
export default DailyDetailPanel;
+193
View File
@@ -0,0 +1,193 @@
import React, { useMemo } from 'react';
import {
Bar,
CartesianGrid,
ComposedChart,
Legend,
Line,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { ChickenWeight } from '../../types/api.ts';
import { formatDateId } from '../../utils/format.ts';
import { formatWeight, getTargetGainForAge } from '../../utils/weight.ts';
interface DailyWeightGainChartProps {
rows: ChickenWeight[];
totalDays: number;
}
type GainStatus = 'above' | 'near' | 'below' | 'none';
type ChartPoint = {
day: number;
date: string;
gain: number | null;
targetGain: number | null;
status: GainStatus;
};
const DailyWeightGainChart: React.FC<DailyWeightGainChartProps> = ({ rows, totalDays }) => {
const chartData = useMemo<ChartPoint[]>(
() =>
[...rows].reverse().map((row) => {
const targetGain = getTargetGainForAge(row.age, totalDays);
const gain = row.average_daily_gain;
let status: GainStatus = 'none';
if (gain != null && targetGain != null) {
const threshold = Math.abs(targetGain) * 0.1;
if (gain >= targetGain + threshold) status = 'above';
else if (gain >= targetGain - threshold) status = 'near';
else status = 'below';
}
return { day: row.age, date: row.date, gain, targetGain, status };
}),
[rows, totalDays]
);
const hasNoData = chartData.length === 0;
const CustomLegend = () => (
<div className="mt-2 flex flex-wrap items-center justify-center gap-6 text-sm text-gray-600">
<div className="flex items-center gap-2">
<div className="h-3 w-8 rounded-sm bg-red-600" />
<span>Pertumbuhan Aktual</span>
</div>
<div className="flex items-center gap-2">
<div className="h-3 w-8 rounded-sm bg-blue-600" />
<span>Target Pertumbuhan</span>
</div>
</div>
);
const CustomTooltip = ({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: ChartPoint }>;
}) => {
if (!active || !payload?.length) return null;
const point = payload[0]?.payload;
if (!point || point.gain == null) return null;
const hasTarget = point.targetGain != null;
const gainDiff = hasTarget ? point.gain - point.targetGain! : null;
return (
<div className="bg-white p-4 border border-gray-300 rounded shadow-lg">
<p className="font-bold text-gray-800">Hari ke-{point.day}</p>
<p className="text-sm text-gray-600">{formatDateId(point.date)}</p>
<div className="mt-2 space-y-1 text-sm">
<p>
<span className="font-semibold text-red-600">Pertumbuhan Aktual:</span>{' '}
{formatWeight(point.gain, { stripTrailingZeros: true })}
</p>
{hasTarget ? (
<>
<p>
<span className="font-semibold text-blue-600">Target Pertumbuhan:</span>{' '}
{formatWeight(point.targetGain!, { stripTrailingZeros: true })}
</p>
<p>
<span className="font-semibold">Selisih:</span>{' '}
<span className={gainDiff! >= 0 ? 'text-green-600' : 'text-orange-600'}>
{gainDiff! >= 0 ? '+' : ''}
{formatWeight(Math.abs(gainDiff!), { stripTrailingZeros: true })}
</span>
</p>
<p>
<span className="font-semibold">Status:</span>{' '}
<span
className={
point.status === 'above'
? 'text-green-600'
: point.status === 'near'
? 'text-yellow-600'
: 'text-red-600'
}
>
{point.status === 'above'
? 'Di Atas Target'
: point.status === 'near'
? 'Mendekati Target'
: 'Di Bawah Target'}
</span>
</p>
</>
) : (
<p>
<span className="font-semibold text-gray-500">Target:</span>{' '}
<span className="text-gray-400">Belum ada data standar</span>
</p>
)}
</div>
</div>
);
};
return (
<div className="bg-white p-6 rounded-xl shadow-sm">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Grafik Kenaikan Berat Harian</h2>
</div>
<div className="h-80">
{hasNoData ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-gray-500 text-lg font-medium">Tidak ada data tersedia</p>
<p className="text-gray-400 text-sm mt-2">Data pertumbuhan belum tersedia</p>
</div>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis
dataKey="day"
tick={{ fill: '#6b7280', fontSize: 12 }}
label={{ value: 'Umur (hari)', position: 'insideBottom', offset: -5 }}
/>
<YAxis
tick={{ fill: '#6b7280', fontSize: 12 }}
tickFormatter={(value) => formatWeight(value, { stripTrailingZeros: true })}
/>
<Legend content={<CustomLegend />} />
<Tooltip content={<CustomTooltip />} />
<ReferenceLine y={0} stroke="#9ca3af" strokeWidth={1} />
<Bar dataKey="gain" fill="#dc2626" radius={[8, 8, 0, 0]} name="Pertumbuhan Aktual" />
<Bar
dataKey="targetGain"
fill="#2563eb"
radius={[8, 8, 0, 0]}
name="Target Pertumbuhan"
/>
<Line
type="monotone"
dataKey="gain"
stroke="#dc2626"
strokeWidth={2}
dot={false}
activeDot={{ r: 5 }}
connectNulls
/>
<Line
type="monotone"
dataKey="targetGain"
stroke="#2563eb"
strokeWidth={2}
dot={false}
activeDot={{ r: 5 }}
connectNulls
/>
</ComposedChart>
</ResponsiveContainer>
)}
</div>
</div>
);
};
export default DailyWeightGainChart;
@@ -0,0 +1,157 @@
import React, { useMemo } from 'react';
import {
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { ChickenWeight } from '../../types/api.ts';
import { formatDateId } from '../../utils/format.ts';
import { formatWeight, getTargetWeightForAge } from '../../utils/weight.ts';
interface DailyWeightProgressionChartProps {
rows: ChickenWeight[];
totalDays: number;
}
type ChartPoint = {
day: number;
date: string;
actual: number | null;
target: number | null;
chickenCount: number;
};
const DailyWeightProgressionChart: React.FC<DailyWeightProgressionChartProps> = ({
rows,
totalDays,
}) => {
const chartData = useMemo<ChartPoint[]>(
() =>
[...rows].reverse().map((row) => ({
day: row.age,
date: row.date,
actual: row.average_weight,
target: getTargetWeightForAge(row.age, totalDays),
chickenCount: row.chicken_count,
})),
[rows, totalDays]
);
const hasNoData = chartData.length === 0;
const CustomTooltip = ({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: ChartPoint }>;
}) => {
if (!active || !payload?.length) return null;
const point = payload[0]?.payload;
if (!point || point.actual == null) return null;
const deviation = point.target != null ? point.actual - point.target : null;
return (
<div className="bg-white p-4 border border-gray-300 rounded shadow-lg">
<p className="font-bold text-gray-800">Hari ke-{point.day}</p>
<p className="text-sm text-gray-600">{formatDateId(point.date)}</p>
<div className="mt-2 space-y-1 text-sm">
<p>
<span className="font-semibold text-red-600">Aktual:</span>{' '}
{formatWeight(point.actual, { stripTrailingZeros: true })}
</p>
<p>
<span className="font-semibold text-blue-600">Target:</span>{' '}
{formatWeight(point.target, { stripTrailingZeros: true })}
</p>
{point.chickenCount > 0 && (
<p>
<span className="font-semibold text-gray-600">Ayam:</span> {point.chickenCount} ekor
</p>
)}
{deviation != null && (
<p>
<span className="font-semibold">Deviasi:</span>{' '}
<span className={deviation >= 0 ? 'text-green-600' : 'text-orange-600'}>
{deviation >= 0 ? '+' : ''}
{formatWeight(Math.abs(deviation), { stripTrailingZeros: true })}
</span>
</p>
)}
</div>
</div>
);
};
return (
<div className="bg-white p-6 rounded-xl shadow-sm h-full">
<h2 className="text-xl font-bold mb-4">Grafik Berat Aktual vs Target</h2>
<div className="h-80">
{hasNoData ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-gray-500 text-lg font-medium">Tidak ada data tersedia</p>
<p className="text-gray-400 text-sm mt-2">
Data berat belum tersedia untuk periode ini
</p>
</div>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis
dataKey="day"
tick={{ fill: '#6b7280', fontSize: 12 }}
label={{ value: 'Umur (hari)', position: 'insideBottom', offset: -5 }}
/>
<YAxis
tick={{ fill: '#6b7280', fontSize: 12 }}
tickFormatter={(value) => formatWeight(value, { stripTrailingZeros: true })}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="monotone"
dataKey="target"
stroke="#3b82f6"
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
connectNulls
name="Target"
/>
<Line
type="monotone"
dataKey="actual"
stroke="#dc2626"
strokeWidth={3}
dot={{ r: 4, fill: '#dc2626' }}
activeDot={{ r: 6 }}
connectNulls
name="Berat Aktual"
/>
</ComposedChart>
</ResponsiveContainer>
)}
</div>
{!hasNoData && (
<div className="mt-4 flex items-center justify-center space-x-6 text-sm text-gray-600">
<div className="flex items-center space-x-2">
<div className="w-8 h-0.5 bg-red-600"></div>
<span>Berat Aktual</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-8 h-0.5 bg-blue-600 border-dashed border-t-2 border-blue-600"></div>
<span>Target Standar</span>
</div>
</div>
)}
</div>
);
};
export default DailyWeightProgressionChart;
+90
View File
@@ -0,0 +1,90 @@
import React from 'react';
import type { LatestAvg } from '../../types/api.ts';
import { formatDateId, formatNumber } from '../../utils/format.ts';
import {
adgStatus,
formatWeight,
getTargetWeightForAge,
type WeightStatus,
uniformityStatus,
weightDeviationStatus,
} from '../../utils/weight.ts';
const statusText: Record<WeightStatus, string> = {
ok: 'text-green-600',
warning: 'text-yellow-600',
critical: 'text-red-600',
};
const StatItem: React.FC<{
icon: React.ReactNode;
label: string;
value: string;
status: WeightStatus;
}> = ({ icon, label, value, status }) => (
<div className="flex items-center p-2 rounded-lg hover:bg-gray-50">
<div className="text-2xl text-gray-400 w-12">{icon}</div>
<div>
<p className="text-sm text-gray-600 font-medium">{label}</p>
<p className={`text-2xl font-bold ${statusText[status]}`}>
<span className="text-base font-medium">{value}</span>
</p>
</div>
</div>
);
interface WeightStatsProps {
latest: LatestAvg | null;
totalDays: number;
}
const WeightStats: React.FC<WeightStatsProps> = ({ latest, totalDays }) => {
const target = latest ? getTargetWeightForAge(latest.age, totalDays) : null;
return (
<div className="bg-white p-6 rounded-xl shadow-sm h-full">
<div className="mb-4">
<h2 className="text-xl font-bold">Statistik Utama Pertumbuhan</h2>
{latest?.date ? (
<p className="text-sm text-gray-500 mt-1">Data per: {formatDateId(latest.date)}</p>
) : null}
</div>
{!latest ? (
<div className="flex flex-col items-center justify-center h-48 text-center">
<i className="fa-solid fa-exclamation-triangle text-5xl text-yellow-500 mb-4"></i>
<h3 className="text-lg font-semibold text-gray-700 mb-2">Data Tidak Tersedia</h3>
<p className="text-sm text-gray-500 max-w-xs">
Belum ada data timbangan untuk siklus ini. Data akan muncul setelah ayam ditimbang.
</p>
</div>
) : (
<div className="space-y-4">
<StatItem
icon={<i className="fa-solid fa-weight-scale"></i>}
label="Rata-rata Berat Ayam"
value={formatWeight(latest.average_weight, { stripTrailingZeros: true })}
status={weightDeviationStatus(latest.average_weight, target)}
/>
<StatItem
icon={<i className="fa-solid fa-chart-line"></i>}
label="Pertambahan Bobot Harian (ADG)"
value={formatWeight(latest.average_daily_gain, {
suffix: '/hari',
decimals: 2,
truncate: true,
})}
status={adgStatus(latest.average_daily_gain)}
/>
<StatItem
icon={<i className="fa-solid fa-percent"></i>}
label="Tingkat Keseragaman (Uniformity)"
value={`${formatNumber(latest.uniformity, 2, 2, true)} %`}
status={uniformityStatus(latest.uniformity)}
/>
</div>
)}
</div>
);
};
export default WeightStats;