first commit
This commit is contained in:
commit
367533c02b
171 files changed
+101175
No files matched your search
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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'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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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(/,$/, '');
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user