first commit
This commit is contained in:
commit
b54624be96
226 files changed
+108840
No files matched your search
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../context/AuthContext.tsx';
|
||||
import UserManagementPage from './UserManagementPage.tsx';
|
||||
|
||||
const AccountAdminShell: React.FC = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const roleLabel = user?.is_superuser ? 'Superuser' : 'Account Admin';
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
<aside className="flex flex-col w-64 bg-white border-r border-gray-200 z-20">
|
||||
<div className="flex items-center h-16 border-b border-gray-200 px-4">
|
||||
<img
|
||||
src="/favicon.png"
|
||||
alt="Logo Charoen Pokphand"
|
||||
className="object-contain h-10"
|
||||
/>
|
||||
</div>
|
||||
<nav className="flex-grow p-2">
|
||||
<ul className="space-y-1">
|
||||
<li>
|
||||
<div className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium bg-red-50 text-red-700">
|
||||
<i className="fa-solid fa-users" aria-hidden />
|
||||
Manajemen Akun
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="border-t border-gray-200 p-4 space-y-2">
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{user?.user_name} · {roleLabel}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void logout()}
|
||||
className="w-full rounded-md border border-gray-300 text-sm px-3 py-2 hover:bg-gray-50"
|
||||
>
|
||||
Keluar
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div className="px-4 sm:px-6 lg:px-8 pt-4 sm:pt-6 lg:pt-8 pb-8">
|
||||
<UserManagementPage />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountAdminShell;
|
||||
@@ -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,172 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import { formatDateId, formatDateRange, resolveCycleStatus } 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 cycleStatus = resolveCycleStatus(cycle);
|
||||
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>
|
||||
{cycleStatus === 'active' && (
|
||||
<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. Data siklus datang dari sinkron site kota.
|
||||
</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[resolveCycleStatus(cycle)];
|
||||
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.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CycleTimeline;
|
||||
@@ -0,0 +1,393 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
|
||||
import type {
|
||||
AIInsight,
|
||||
ChickenCounting,
|
||||
ChickenWeight,
|
||||
Karung,
|
||||
KPI,
|
||||
LatestAvg,
|
||||
ManualInput,
|
||||
} from '../types/api.ts';
|
||||
import type { View } from '../types/common.ts';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import KpiCard, { type KpiCardModel, type KpiTrendPoint } from './KpiCard.tsx';
|
||||
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
||||
import {
|
||||
cycleDayForDate,
|
||||
formatDateId,
|
||||
formatDateRange,
|
||||
formatNumber,
|
||||
resolveCycleStatus,
|
||||
} from '../utils/format.ts';
|
||||
import { isOnOrBeforeVisibleDate, throughVisibleDate } from '../utils/visibility.ts';
|
||||
import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts';
|
||||
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
|
||||
import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx';
|
||||
import CountingAccuracyWarningBanner from './counting/CountingAccuracyWarningBanner.tsx';
|
||||
import PendingCloseQueue from './PendingCloseQueue.tsx';
|
||||
import KandangRankingTop5 from './dashboard/KandangRankingTop5.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 [manualInputs, setManualInputs] = useState<ManualInput[]>([]);
|
||||
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([]);
|
||||
setManualInputs([]);
|
||||
setWeights([]);
|
||||
setKarung(null);
|
||||
setLatestAvg(null);
|
||||
setInsights([]);
|
||||
return;
|
||||
}
|
||||
const cycleId = selectedCycle.id;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [kpiRows, countingRows, manualRows, weightRows, insightRows] = await Promise.all([
|
||||
api.kpis.list({ cycle_id: cycleId }),
|
||||
api.countings.list({ cycle_id: cycleId }),
|
||||
api.manualInputs.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);
|
||||
setManualInputs(manualRows);
|
||||
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(() => {
|
||||
// Countings/weights APIs are real-time; KPI cards still honor publish cutoff.
|
||||
const visibleThrough = selectedCycle?.visible_through_date;
|
||||
const visibleKpis = throughVisibleDate(kpis, visibleThrough);
|
||||
const visibleCountings = throughVisibleDate(countings, visibleThrough);
|
||||
const visibleWeights = throughVisibleDate(weights, visibleThrough);
|
||||
const visibleLatestAvg =
|
||||
latestAvg && isOnOrBeforeVisibleDate(latestAvg.date, visibleThrough) ? latestAvg : null;
|
||||
const visibleKarung =
|
||||
karung && isOnOrBeforeVisibleDate(karung.date, visibleThrough) ? karung : null;
|
||||
|
||||
const latest = visibleKpis[0];
|
||||
const startDate = selectedCycle?.start_date;
|
||||
const trendLabel = (isoDate: string): string => {
|
||||
const day = startDate ? cycleDayForDate(startDate, isoDate) : 0;
|
||||
return `Hari ${day} · ${formatDateId(isoDate)}`;
|
||||
};
|
||||
const fcrTrend: KpiTrendPoint[] = chronological(visibleKpis).map((row) => ({
|
||||
value: row.fcr,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const eefTrend: KpiTrendPoint[] = chronological(visibleKpis).map((row) => ({
|
||||
value: row.eef,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const mortalityTrend: KpiTrendPoint[] = chronological(visibleCountings).map((row) => ({
|
||||
value: row.mortality_count,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const mortalityToday = latest?.mortality ?? visibleCountings[0]?.mortality_count ?? null;
|
||||
const weightTrend: KpiTrendPoint[] = chronological(visibleWeights).map((row) => ({
|
||||
value: row.average_weight,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const age = selectedCycle?.current_day ?? latest?.age ?? null;
|
||||
return [
|
||||
{
|
||||
key: 'umur',
|
||||
title: 'Umur',
|
||||
value: age,
|
||||
unit: 'hari',
|
||||
icon: '📅',
|
||||
status: age == null ? 'na' : 'ok',
|
||||
},
|
||||
{
|
||||
key: 'mortality',
|
||||
title: 'Mortalitas',
|
||||
value: mortalityToday,
|
||||
unit: 'ekor',
|
||||
icon: '📉',
|
||||
trend: mortalityTrend,
|
||||
status: mortalityToday == null ? 'na' : 'ok',
|
||||
targetView: 'dataManagement',
|
||||
},
|
||||
{
|
||||
key: 'karung',
|
||||
title: 'Karung Dituang',
|
||||
value: visibleKarung?.feed_use_today ?? visibleKarung?.out_today ?? null,
|
||||
unit: 'karung',
|
||||
icon: '🛢️',
|
||||
status: visibleKarung ? 'ok' : 'na',
|
||||
targetView: 'feedSackCounting',
|
||||
},
|
||||
{
|
||||
key: 'weight',
|
||||
title: 'Berat AVG',
|
||||
value: visibleLatestAvg?.average_weight ?? visibleWeights[0]?.average_weight ?? null,
|
||||
unit: 'gram',
|
||||
icon: '⚖️',
|
||||
trend: weightTrend,
|
||||
status: visibleLatestAvg || visibleWeights[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]);
|
||||
|
||||
const countingAccuracyWarning = useMemo(() => {
|
||||
if (!selectedCycle) return null;
|
||||
return getCountingAccuracyWarning({
|
||||
cycle: selectedCycle,
|
||||
countings,
|
||||
kpis,
|
||||
manualInputs,
|
||||
});
|
||||
}, [selectedCycle, countings, kpis, manualInputs]);
|
||||
|
||||
if (!selectedCycle) {
|
||||
return (
|
||||
<div>
|
||||
<PendingCloseQueue />
|
||||
<EmptyFarm onSettings={() => setView('kandangSettings')} />
|
||||
<KandangRankingTop5 setView={setView} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const feedTotal = karung?.feed_use_total ?? kpis[0]?.feed_total ?? 0;
|
||||
const feedWeightKg = feedTotal * 50;
|
||||
const feedInitialBalance = selectedCycle?.feed_initial_balance ?? 0;
|
||||
const feedInTotal = (karung?.in_total ?? 0) + feedInitialBalance;
|
||||
const feedOutTotal = karung?.out_total ?? 0;
|
||||
const feedSaldo = feedInTotal - feedTotal - feedOutTotal;
|
||||
const feedSaldoColor =
|
||||
feedSaldo > 0 ? 'text-green-600' : feedSaldo < 0 ? 'text-red-600' : 'text-gray-600';
|
||||
const cycleStatus = resolveCycleStatus(selectedCycle);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PendingCloseQueue />
|
||||
<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)} (${cycleStatus === 'active' ? 'aktif' : 'selesai'})`}
|
||||
/>
|
||||
<DashboardReportCtas
|
||||
cycleClosed={cycleStatus === '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>
|
||||
)}
|
||||
{countingAccuracyWarning && (
|
||||
<CountingAccuracyWarningBanner warning={countingAccuracyWarning} />
|
||||
)}
|
||||
{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 Karung Tuang</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 Karung Masuk</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 Karung Keluar</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>
|
||||
<KandangRankingTop5 setView={setView} />
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<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,321 @@
|
||||
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, resolveCycleStatus, 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 visibleThrough = selectedCycle?.visible_through_date;
|
||||
// Display date follows the latest IoT snapshot; without any snapshot, fall back to today.
|
||||
// Manual cards only use a same-date row — never fall back to an older day.
|
||||
const displayDate = latest?.date ?? visibleThrough ?? todayIso();
|
||||
const latestManual = sortedManualRows.find((row) => row.date === displayDate) ?? null;
|
||||
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} />;
|
||||
|
||||
const cycleStatus = resolveCycleStatus(selectedCycle);
|
||||
|
||||
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 || cycleStatus !== '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>
|
||||
{cycleStatus === 'active' && (
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded">
|
||||
Aktif
|
||||
</span>
|
||||
)}
|
||||
{cycleStatus === '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) => {
|
||||
const itemStatus = resolveCycleStatus(cycle);
|
||||
return (
|
||||
<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">
|
||||
{itemStatus === '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>
|
||||
{itemStatus === 'active' && (
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded">
|
||||
Aktif
|
||||
</span>
|
||||
)}
|
||||
{itemStatus === '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} />
|
||||
|
||||
{/* Ringkasan stats */}
|
||||
<FeedStats
|
||||
latest={latest}
|
||||
latestManual={latestManual}
|
||||
cycleStartDate={selectedCycle.start_date}
|
||||
displayDate={displayDate}
|
||||
/>
|
||||
|
||||
{/* Saldo Karung */}
|
||||
<FeedSaldoCard
|
||||
latest={latest}
|
||||
feedInitialBalance={selectedCycle.feed_initial_balance}
|
||||
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,217 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext.tsx';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import type { UserStatus } from '../types/api.ts';
|
||||
import { formatDateId, resolveCycleStatus } from '../utils/format.ts';
|
||||
|
||||
const ROLE_LABELS: Record<UserStatus, string> = {
|
||||
inactive: 'Inactive',
|
||||
gm: 'GM',
|
||||
gm_admin: 'GM Admin',
|
||||
buh: 'BUH',
|
||||
bu_admin: 'BU Admin',
|
||||
director: 'Director',
|
||||
director_admin: 'Director Admin',
|
||||
account_admin: 'Account Admin',
|
||||
superuser: 'Superuser',
|
||||
};
|
||||
|
||||
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 [gmFilter, setGmFilter] = useState<'all' | number>('all');
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isBuh = user?.status === 'buh' || user?.status === 'bu_admin';
|
||||
|
||||
const gmOptions = useMemo(() => {
|
||||
const byId = new Map<number, string>();
|
||||
for (const site of sites) {
|
||||
if (!byId.has(site.user)) {
|
||||
byId.set(site.user, site.owner_name ?? `GM #${site.user}`);
|
||||
}
|
||||
}
|
||||
return [...byId.entries()]
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [sites]);
|
||||
|
||||
const visibleSites = useMemo(() => {
|
||||
if (!isBuh || gmFilter === 'all') return sites;
|
||||
return sites.filter((site) => site.user === gmFilter);
|
||||
}, [gmFilter, isBuh, sites]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBuh || gmFilter === 'all') return;
|
||||
if (selectedSiteId == null) return;
|
||||
const stillVisible = visibleSites.some((site) => site.id === selectedSiteId);
|
||||
if (!stillVisible) {
|
||||
setSelectedSiteId(visibleSites[0]?.id ?? null);
|
||||
}
|
||||
}, [gmFilter, isBuh, selectedSiteId, setSelectedSiteId, visibleSites]);
|
||||
|
||||
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>
|
||||
{isBuh && gmOptions.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<i className="fa-solid fa-user-tie text-gray-500" />
|
||||
<select
|
||||
value={gmFilter === 'all' ? 'all' : String(gmFilter)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setGmFilter(value === 'all' ? 'all' : Number(value));
|
||||
}}
|
||||
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"
|
||||
aria-label="Filter GM"
|
||||
>
|
||||
<option value="all">Semua GM</option>
|
||||
{gmOptions.map((gm) => (
|
||||
<option key={gm.id} value={gm.id}>
|
||||
{gm.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<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"
|
||||
>
|
||||
{visibleSites.length === 0 && <option value="">Tidak ada site</option>}
|
||||
{visibleSites.map((site) => (
|
||||
<option key={site.id} value={site.id}>
|
||||
{isBuh && site.owner_name
|
||||
? `${site.site_name} (${site.owner_name})`
|
||||
: 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)} (
|
||||
{resolveCycleStatus(cycle) === '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">{ROLE_LABELS[user.status]}</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">{ROLE_LABELS[user.status]}</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 (!selectedKandang) {
|
||||
setFlocks([]);
|
||||
setSelectedFlockId(null);
|
||||
setDates([]);
|
||||
setSelectedDate('');
|
||||
setDayRows([]);
|
||||
setInsights([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.flocks.list({ kandang_id: selectedKandang.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;
|
||||
};
|
||||
}, [selectedKandang, 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,208 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { errorMessage } from '../services/apiClient.ts';
|
||||
import type { View } from '../types/common.ts';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
||||
import { formatDateRange, formatRatio } from '../utils/format.ts';
|
||||
import {
|
||||
loadKandangRanking,
|
||||
type KandangRankingRow,
|
||||
} from '../utils/kandangRanking.ts';
|
||||
|
||||
type ScopeMode = 'selected' | 'all';
|
||||
|
||||
type Props = {
|
||||
setView: (view: View) => void;
|
||||
};
|
||||
|
||||
const MONTH_OPTIONS = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||
|
||||
const formatMetric = (value: number | null): string =>
|
||||
value == null ? 'N/A' : formatRatio(value, 3);
|
||||
|
||||
const KandangRankingPage: React.FC<Props> = ({ setView }) => {
|
||||
const {
|
||||
sites,
|
||||
selectedSite,
|
||||
selectedSiteId,
|
||||
setSelectedSiteId,
|
||||
setSelectedKandangId,
|
||||
setSelectedCycleId,
|
||||
} = useFarm();
|
||||
|
||||
const [scope, setScope] = useState<ScopeMode>('selected');
|
||||
const [months, setMonths] = useState(3);
|
||||
const [rows, setRows] = useState<KandangRankingRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const needsSite = scope === 'selected' && selectedSiteId == null;
|
||||
|
||||
useEffect(() => {
|
||||
if (needsSite) {
|
||||
setRows([]);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSites =
|
||||
scope === 'all' ? sites : selectedSite ? [selectedSite] : [];
|
||||
if (targetSites.length === 0) {
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await loadKandangRanking({ sites: targetSites, months });
|
||||
if (!cancelled) setRows(next);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(errorMessage(err));
|
||||
setRows([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [scope, months, sites, selectedSite, selectedSiteId, needsSite]);
|
||||
|
||||
const openEef = (row: KandangRankingRow) => {
|
||||
setSelectedSiteId(row.siteId);
|
||||
setSelectedKandangId(row.kandangId);
|
||||
setSelectedCycleId(row.cycleId);
|
||||
setView('eefDashboard');
|
||||
};
|
||||
|
||||
if (needsSite) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Peringkat Kandang"
|
||||
subtitle="Peringkat siklus tertutup berdasarkan EEF"
|
||||
/>
|
||||
<EmptyFarm onSettings={() => setView('kandangSettings')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showSiteColumn = scope === 'all';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-4">
|
||||
<PageHeader
|
||||
title="Peringkat Kandang"
|
||||
subtitle={
|
||||
scope === 'all'
|
||||
? 'Semua site · siklus tertutup berdasarkan EEF'
|
||||
: `${selectedSite?.site_name ?? 'Site'} · siklus tertutup berdasarkan EEF`
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="inline-flex rounded-lg border border-gray-200 bg-white p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`px-3 py-1.5 text-xs font-semibold rounded-md ${
|
||||
scope === 'selected'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setScope('selected')}
|
||||
>
|
||||
Site terpilih
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-3 py-1.5 text-xs font-semibold rounded-md ${
|
||||
scope === 'all'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setScope('all')}
|
||||
>
|
||||
Semua site
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<span className="font-semibold">Rentang</span>
|
||||
<select
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm font-semibold text-gray-800"
|
||||
value={months}
|
||||
onChange={(e) => setMonths(Number(e.target.value))}
|
||||
>
|
||||
{MONTH_OPTIONS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m} bulan
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<Banner kind="error">{error}</Banner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : rows.length === 0 ? (
|
||||
<Banner>Tidak ada siklus tertutup dalam rentang ini.</Banner>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 text-left text-xs uppercase tracking-wide text-gray-500">
|
||||
<th className="px-4 py-3 font-semibold">Rank</th>
|
||||
{showSiteColumn && <th className="px-4 py-3 font-semibold">Site</th>}
|
||||
<th className="px-4 py-3 font-semibold">Kandang</th>
|
||||
<th className="px-4 py-3 font-semibold">Periode</th>
|
||||
<th className="px-4 py-3 font-semibold">Hari</th>
|
||||
<th className="px-4 py-3 font-semibold">EEF</th>
|
||||
<th className="px-4 py-3 font-semibold">FCR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={`${row.cycleId}-${row.kandangId}`}
|
||||
className="border-b border-gray-50 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => openEef(row)}
|
||||
>
|
||||
<td className="px-4 py-3 font-semibold text-gray-800">
|
||||
{row.rank > 0 ? row.rank : '—'}
|
||||
</td>
|
||||
{showSiteColumn && (
|
||||
<td className="px-4 py-3 text-gray-700">{row.siteName}</td>
|
||||
)}
|
||||
<td className="px-4 py-3 font-medium text-gray-800">{row.kandangName}</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{formatDateRange(row.startDate, row.endDate)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{row.totalDays || '—'}</td>
|
||||
<td className="px-4 py-3 font-semibold text-gray-900">
|
||||
{formatMetric(row.eef)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{formatMetric(row.fcr)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KandangRankingPage;
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis } 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 KpiTrendPoint = {
|
||||
value: number;
|
||||
/** Tooltip label, e.g. ``Hari 0 · 2 Sep 2026``. */
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type KpiCardModel = {
|
||||
key: string;
|
||||
title: string;
|
||||
value: number | null;
|
||||
unit: string;
|
||||
icon: string;
|
||||
trend?: KpiTrendPoint[];
|
||||
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) => ({ name: point.label, value: point.value })),
|
||||
[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 }}>
|
||||
<XAxis dataKey="name" hide />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={styles.line}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(val) => [formatNumber(Number(val ?? 0), 2), title]}
|
||||
labelFormatter={(label) => String(label ?? '')}
|
||||
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,106 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { api, errorMessage } from '../services/apiClient.ts';
|
||||
import { useAuth } from '../context/AuthContext.tsx';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import { Banner } from './ui/Feedback.tsx';
|
||||
import { formatDateId, resolveCycleStatus } from '../utils/format.ts';
|
||||
|
||||
/** GM queue: approve/reject pending cycle closes from city sites. */
|
||||
const PendingCloseQueue: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const { cycles, sites, kandangs, reload } = useFarm();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
|
||||
const isGm = user?.status === 'gm';
|
||||
const pending = useMemo(
|
||||
() => cycles.filter((c) => resolveCycleStatus(c) === 'pending_close'),
|
||||
[cycles]
|
||||
);
|
||||
|
||||
if (pending.length === 0) return null;
|
||||
|
||||
const siteName = (kandangId: number) => {
|
||||
const kandang = kandangs.find((k) => k.id === kandangId);
|
||||
const site = sites.find((s) => s.id === kandang?.site);
|
||||
return site?.site_name ?? '—';
|
||||
};
|
||||
|
||||
const act = async (id: number, decision: 'approve' | 'reject') => {
|
||||
setBusyId(id);
|
||||
setError(null);
|
||||
try {
|
||||
const result =
|
||||
decision === 'approve'
|
||||
? await api.cycles.approveClose(id)
|
||||
: await api.cycles.rejectClose(id);
|
||||
const siteError =
|
||||
result && typeof result === 'object' && 'site_error' in result
|
||||
? (result as { site_error?: string }).site_error
|
||||
: undefined;
|
||||
setNotice(
|
||||
siteError
|
||||
? `Keputusan tersimpan di pusat. ${siteError}`
|
||||
: decision === 'approve'
|
||||
? 'Siklus ditutup.'
|
||||
: 'Permintaan ditolak.'
|
||||
);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-white rounded-xl shadow-sm p-5 mb-6">
|
||||
<h2 className="font-semibold text-gray-800 mb-1">Permintaan tutup siklus</h2>
|
||||
<p className="text-xs text-gray-500 mb-3">Menunggu persetujuan GM</p>
|
||||
{error && <Banner kind="error">{error}</Banner>}
|
||||
{notice && <Banner kind="success">{notice}</Banner>}
|
||||
<ul className="divide-y">
|
||||
{pending.map((cycle) => (
|
||||
<li key={cycle.id} className="py-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm text-gray-800">
|
||||
<p className="font-medium">
|
||||
{siteName(cycle.kandang)} · {cycle.kandang_name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Mulai {formatDateId(cycle.start_date)}
|
||||
{cycle.proposed_end_date
|
||||
? ` · usulan tutup ${formatDateId(cycle.proposed_end_date)}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
{isGm ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === cycle.id}
|
||||
className="bg-red-600 text-white px-3 py-1.5 rounded-lg text-xs font-semibold disabled:opacity-50"
|
||||
onClick={() => void act(cycle.id, 'approve')}
|
||||
>
|
||||
Setujui
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === cycle.id}
|
||||
className="border border-gray-300 text-gray-700 px-3 py-1.5 rounded-lg text-xs font-semibold hover:bg-gray-50 disabled:opacity-50"
|
||||
onClick={() => void act(cycle.id, 'reject')}
|
||||
>
|
||||
Tolak
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Hanya GM yang dapat menyetujui</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingCloseQueue;
|
||||
@@ -0,0 +1,557 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api, errorMessage } from '../services/apiClient.ts';
|
||||
import { useAuth } from '../context/AuthContext.tsx';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import type { CitySite, User, UserStatus } from '../types/api.ts';
|
||||
import { Banner, PageHeader, Spinner } from './ui/Feedback.tsx';
|
||||
|
||||
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 ROLE_LABELS: Partial<Record<UserStatus, string>> = {
|
||||
gm: 'GM',
|
||||
gm_admin: 'GM Admin',
|
||||
buh: 'BUH',
|
||||
bu_admin: 'BU Admin',
|
||||
account_admin: 'Account Admin',
|
||||
};
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
function isGmRole(status?: UserStatus | string | null): boolean {
|
||||
return status === 'gm' || status === 'gm_admin';
|
||||
}
|
||||
|
||||
function isBuhRole(status?: UserStatus | string | null): boolean {
|
||||
return status === 'buh' || status === 'bu_admin';
|
||||
}
|
||||
|
||||
const BuhViewList: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const isPrimaryBuh = user?.status === 'buh';
|
||||
const [gms, setGms] = useState<User[]>([]);
|
||||
const [pending, setPending] = useState<User[]>([]);
|
||||
const [citySites, setCitySites] = useState<CitySite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nextUsers = await api.users.list();
|
||||
const nextSites = await api.citySites.list();
|
||||
const nextPending = isPrimaryBuh ? await api.users.listPendingManaged() : [];
|
||||
setGms(nextUsers.filter((u) => u.status === 'gm'));
|
||||
setPending(nextPending);
|
||||
setCitySites(nextSites);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isPrimaryBuh]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const sitesByGm = useMemo(() => {
|
||||
const map = new Map<number, CitySite[]>();
|
||||
for (const site of citySites) {
|
||||
if (site.registered_by == null) continue;
|
||||
const rows = map.get(site.registered_by) ?? [];
|
||||
rows.push(site);
|
||||
map.set(site.registered_by, rows);
|
||||
}
|
||||
return map;
|
||||
}, [citySites]);
|
||||
|
||||
const decide = async (gmId: number, decision: 'approve' | 'reject') => {
|
||||
setBusyId(gmId);
|
||||
setError(null);
|
||||
try {
|
||||
if (decision === 'approve') {
|
||||
await api.users.approveManagedBy(gmId);
|
||||
setNotice('GM dikonfirmasi di bawah pengelolaan Anda.');
|
||||
} else {
|
||||
await api.users.rejectManagedBy(gmId);
|
||||
setNotice('Permintaan GM ditolak.');
|
||||
}
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Pengaturan"
|
||||
subtitle={
|
||||
isPrimaryBuh
|
||||
? 'Konfirmasi GM di bawah Anda, lalu lihat site yang mereka daftarkan'
|
||||
: 'Lihat site dari GM yang terhubung (konfirmasi hanya oleh BUH)'
|
||||
}
|
||||
/>
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<Banner kind="error">{error}</Banner>
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="mb-4">
|
||||
<Banner kind="success">{notice}</Banner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPrimaryBuh && (
|
||||
<section className="bg-white rounded-xl shadow-sm p-5 mb-6">
|
||||
<h2 className="font-semibold text-gray-800 mb-1">Permintaan GM</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
GM memilih Anda sebagai BUH. Konfirmasi jika mereka memang di bawah pengawasan Anda.
|
||||
</p>
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : pending.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Tidak ada permintaan menunggu.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{pending.map((gm) => (
|
||||
<li
|
||||
key={gm.id}
|
||||
className="py-3 flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800">{gm.user_name}</p>
|
||||
<p className="text-xs text-gray-500">{ROLE_LABELS[gm.status] ?? gm.status}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === gm.id}
|
||||
className="bg-red-600 text-white px-3 py-1.5 rounded-lg text-xs font-semibold disabled:opacity-50"
|
||||
onClick={() => void decide(gm.id, 'approve')}
|
||||
>
|
||||
Konfirmasi
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === gm.id}
|
||||
className="border border-gray-300 text-gray-700 px-3 py-1.5 rounded-lg text-xs font-semibold hover:bg-gray-50 disabled:opacity-50"
|
||||
onClick={() => void decide(gm.id, 'reject')}
|
||||
>
|
||||
Tolak
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 className="font-semibold text-gray-800 mb-1">GM yang dikelola</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Sudah dikonfirmasi (akun GM saja). Site di daftar tampilan mereka otomatis masuk rollup
|
||||
Anda.
|
||||
</p>
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : gms.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Belum ada GM yang terkonfirmasi di bawah Anda.</p>
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{gms.map((gm) => {
|
||||
const sites = sitesByGm.get(gm.id) ?? [];
|
||||
return (
|
||||
<li key={gm.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 mb-2">
|
||||
<p className="text-sm font-semibold text-gray-800">{gm.user_name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{ROLE_LABELS[gm.status] ?? gm.status} · {sites.length} site
|
||||
</p>
|
||||
</div>
|
||||
{sites.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">Belum ada site di daftar tampilan GM ini.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{sites.map((site) => (
|
||||
<li key={site.id} className="py-2">
|
||||
<p className="text-sm text-gray-800">{site.name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{site.code}
|
||||
{site.is_active ? '' : ' · nonaktif'}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GmViewList: React.FC = () => {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const { reload: reloadFarm } = useFarm();
|
||||
const [citySites, setCitySites] = useState<CitySite[]>([]);
|
||||
const [buhs, setBuhs] = useState<User[]>([]);
|
||||
const [selectedBuhId, setSelectedBuhId] = useState<number | ''>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [buhBusy, setBuhBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [codeTouched, setCodeTouched] = useState(false);
|
||||
const [apiBaseUrl, setApiBaseUrl] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
|
||||
const flash = (msg: string) => {
|
||||
setNotice(msg);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const sitesPromise = api.citySites.list();
|
||||
const mePromise = refreshUser();
|
||||
const buhsPromise =
|
||||
user?.status === 'gm' ? api.users.listBuhs() : Promise.resolve([] as User[]);
|
||||
const [sites, me, nextBuhs] = await Promise.all([sitesPromise, mePromise, buhsPromise]);
|
||||
setCitySites(sites);
|
||||
setBuhs(nextBuhs);
|
||||
if (me?.managed_by_pending) {
|
||||
setSelectedBuhId(me.managed_by_pending);
|
||||
} else if (me?.managed_by) {
|
||||
setSelectedBuhId(me.managed_by);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refreshUser, user?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setCode('');
|
||||
setCodeTouched(false);
|
||||
setApiBaseUrl('');
|
||||
setApiKey('');
|
||||
};
|
||||
|
||||
const wrap = async (fn: () => Promise<void>) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
await reload();
|
||||
await reloadFarm();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitBuhRequest = async () => {
|
||||
if (selectedBuhId === '') return;
|
||||
setBuhBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.users.requestManagedBy(selectedBuhId);
|
||||
await refreshUser();
|
||||
flash('Permintaan dikirim. Menunggu konfirmasi BUH.');
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBuhBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelBuhRequest = async () => {
|
||||
setBuhBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.users.cancelManagedByRequest();
|
||||
await refreshUser();
|
||||
flash('Permintaan dibatalkan.');
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBuhBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Pengaturan"
|
||||
subtitle="Pilih BUH Anda, lalu daftarkan site ke daftar tampilan"
|
||||
/>
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<Banner kind="error">{error}</Banner>
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="mb-4">
|
||||
<Banner kind="success">{notice}</Banner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user?.status === 'gm' && (
|
||||
<section className="bg-white rounded-xl shadow-sm p-5 mb-6">
|
||||
<h2 className="font-semibold text-gray-800 mb-1">BUH pengelola</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Pilih BUH yang membawahi Anda. Mereka harus mengonfirmasi sebelum Anda masuk ke rollup
|
||||
mereka.
|
||||
</p>
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className="space-y-3 max-w-xl">
|
||||
{user?.managed_by_name && (
|
||||
<p className="text-sm text-gray-700">
|
||||
Saat ini dikonfirmasi di bawah:{' '}
|
||||
<strong className="text-gray-900">{user.managed_by_name}</strong>
|
||||
</p>
|
||||
)}
|
||||
{user?.managed_by_pending_name && (
|
||||
<p className="text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
Menunggu konfirmasi dari <strong>{user.managed_by_pending_name}</strong>
|
||||
</p>
|
||||
)}
|
||||
<label className="block text-sm text-gray-700">
|
||||
Pilih BUH
|
||||
<select
|
||||
className={`${inputClass} mt-1`}
|
||||
value={selectedBuhId === '' ? '' : String(selectedBuhId)}
|
||||
onChange={(e) =>
|
||||
setSelectedBuhId(e.target.value ? Number(e.target.value) : '')
|
||||
}
|
||||
>
|
||||
<option value="">— pilih —</option>
|
||||
{buhs.map((buh) => (
|
||||
<option key={buh.id} value={buh.id}>
|
||||
{buh.user_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
buhBusy ||
|
||||
selectedBuhId === '' ||
|
||||
(user?.managed_by === selectedBuhId && !user.managed_by_pending) ||
|
||||
user?.managed_by_pending === selectedBuhId
|
||||
}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-50"
|
||||
onClick={() => void submitBuhRequest()}
|
||||
>
|
||||
{buhBusy ? 'Mengirim...' : 'Kirim permintaan'}
|
||||
</button>
|
||||
{user?.managed_by_pending != null && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={buhBusy}
|
||||
className="border border-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-semibold hover:bg-gray-50 disabled:opacity-50"
|
||||
onClick={() => void cancelBuhRequest()}
|
||||
>
|
||||
Batalkan permintaan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<section className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 className="font-semibold text-gray-800 mb-1">Tambah site</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Daftarkan URL API dasbor kota agar data site muncul di daftar tampilan Anda.
|
||||
</p>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const resolvedCode = code || slugify(name);
|
||||
if (!resolvedCode) return;
|
||||
void wrap(async () => {
|
||||
const created = await api.citySites.create({
|
||||
code: resolvedCode,
|
||||
name: name.trim(),
|
||||
api_base_url: apiBaseUrl.trim(),
|
||||
...(apiKey.trim() ? { api_key: apiKey.trim() } : {}),
|
||||
is_active: true,
|
||||
});
|
||||
resetForm();
|
||||
flash(`Site ${created.name} ditambahkan ke daftar tampilan.`);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="block text-sm text-gray-700">
|
||||
Nama site
|
||||
<input
|
||||
className={`${inputClass} mt-1`}
|
||||
placeholder="Contoh: Sukawarna"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setName(next);
|
||||
if (!codeTouched) setCode(slugify(next));
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700">
|
||||
Kode
|
||||
<input
|
||||
className={`${inputClass} mt-1`}
|
||||
placeholder="sukawarna"
|
||||
value={code}
|
||||
onChange={(e) => {
|
||||
setCodeTouched(true);
|
||||
setCode(slugify(e.target.value));
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700">
|
||||
URL API kota
|
||||
<input
|
||||
className={`${inputClass} mt-1`}
|
||||
type="url"
|
||||
placeholder="https://site.example/api/v1"
|
||||
value={apiBaseUrl}
|
||||
onChange={(e) => setApiBaseUrl(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700">
|
||||
API key (opsional)
|
||||
<input
|
||||
className={`${inputClass} mt-1`}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="X-API-Key untuk sinkron dari kota"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-50"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Menyimpan...' : 'Tambah ke daftar'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 className="font-semibold text-gray-800 mb-1">Daftar tampilan</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Site yang terdaftar untuk akun GM / GM Admin Anda.
|
||||
</p>
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : citySites.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Belum ada site di daftar tampilan.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{citySites.map((site) => (
|
||||
<li key={site.id} className="py-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-800 truncate">{site.name}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{site.code}</p>
|
||||
<p className="text-xs text-gray-400 truncate mt-0.5">{site.api_base_url}</p>
|
||||
<p className="text-xs mt-1">
|
||||
<span
|
||||
className={
|
||||
site.is_active
|
||||
? 'text-green-600 font-medium'
|
||||
: 'text-gray-400 font-medium'
|
||||
}
|
||||
>
|
||||
{site.is_active ? 'Aktif' : 'Nonaktif'}
|
||||
</span>
|
||||
{site.has_api_key ? ' · API key tersimpan' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red-500 hover:underline shrink-0"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
void wrap(async () => {
|
||||
await api.citySites.remove(site.id);
|
||||
flash(`Site ${site.name} dihapus dari daftar tampilan.`);
|
||||
})
|
||||
}
|
||||
>
|
||||
Hapus
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (isGmRole(user?.status)) {
|
||||
return <GmViewList />;
|
||||
}
|
||||
|
||||
if (isBuhRole(user?.status)) {
|
||||
return <BuhViewList />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Pengaturan" subtitle="Daftar site di dasbor ini" />
|
||||
<Banner kind="error">
|
||||
Pengaturan daftar tampilan tersedia untuk GM, GM Admin, BUH, dan BU Admin.
|
||||
</Banner>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
@@ -0,0 +1,151 @@
|
||||
import React from 'react';
|
||||
import { View } from '../types/common.ts';
|
||||
import { useAuth } from '../context/AuthContext.tsx';
|
||||
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 Mr. Mike',
|
||||
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: 'kandangRanking',
|
||||
label: 'Peringkat Kandang',
|
||||
icon: <i className="fa-solid fa-ranking-star"></i>,
|
||||
},
|
||||
{
|
||||
view: 'kandangSettings',
|
||||
label: 'Pengaturan',
|
||||
icon: <i className="fa-solid fa-gear"></i>,
|
||||
},
|
||||
{
|
||||
view: 'userManagement',
|
||||
label: 'Manajemen Akun',
|
||||
icon: <i className="fa-solid fa-users"></i>,
|
||||
},
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
isCollapsed: boolean;
|
||||
view: View;
|
||||
setView: (view: View) => void;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ isCollapsed, view, setView, onToggleCollapse }) => {
|
||||
const { user } = useAuth();
|
||||
const canOpenSettings =
|
||||
user?.status === 'gm' ||
|
||||
user?.status === 'gm_admin' ||
|
||||
user?.status === 'buh' ||
|
||||
user?.status === 'bu_admin';
|
||||
const canOpenUserManagement =
|
||||
user?.status === 'superuser' || Boolean(user?.is_superuser);
|
||||
const visibleNav = navItems.filter((item) => {
|
||||
if (item.view === 'kandangSettings') return canOpenSettings;
|
||||
if (item.view === 'userManagement') return canOpenUserManagement;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<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">
|
||||
{visibleNav.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,362 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { api, errorMessage } from '../services/apiClient.ts';
|
||||
import { useAuth } from '../context/AuthContext.tsx';
|
||||
import type { User, UserStatus } from '../types/api.ts';
|
||||
import { Banner, PageHeader, Spinner } from './ui/Feedback.tsx';
|
||||
|
||||
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 ROLE_LABELS: Record<UserStatus, string> = {
|
||||
inactive: 'Inactive',
|
||||
gm: 'GM',
|
||||
gm_admin: 'GM Admin',
|
||||
buh: 'BUH',
|
||||
bu_admin: 'BU Admin',
|
||||
director: 'Director',
|
||||
director_admin: 'Director Admin',
|
||||
account_admin: 'Account Admin',
|
||||
superuser: 'Superuser',
|
||||
};
|
||||
|
||||
const ASSIGNABLE_STATUSES: UserStatus[] = [
|
||||
'gm',
|
||||
'gm_admin',
|
||||
'buh',
|
||||
'bu_admin',
|
||||
'director',
|
||||
'director_admin',
|
||||
'account_admin',
|
||||
'inactive',
|
||||
];
|
||||
|
||||
const UserManagementPage: React.FC = () => {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const [userName, setUserName] = useState('');
|
||||
const [status, setStatus] = useState<UserStatus>('gm');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [editStatus, setEditStatus] = useState<UserStatus>('gm');
|
||||
const [resetId, setResetId] = useState<number | null>(null);
|
||||
const [resetPassword, setResetPassword] = useState('');
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setUsers(await api.users.list());
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const wrap = async (fn: () => Promise<void>) => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const onCreate = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
void wrap(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.users.create({
|
||||
user_name: userName.trim(),
|
||||
status,
|
||||
password,
|
||||
});
|
||||
setNotice(
|
||||
`Akun "${userName.trim()}" dibuat. Bagikan password sementara secara aman (di luar sistem).`
|
||||
);
|
||||
setUserName('');
|
||||
setPassword('');
|
||||
setStatus('gm');
|
||||
await reload();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSaveStatus = (id: number) => {
|
||||
void wrap(async () => {
|
||||
setBusyId(id);
|
||||
try {
|
||||
await api.users.update(id, { status: editStatus });
|
||||
setNotice('Status pengguna diperbarui.');
|
||||
setEditId(null);
|
||||
await reload();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onDeactivate = (user: User) => {
|
||||
if (!window.confirm(`Nonaktifkan akun "${user.user_name}"?`)) return;
|
||||
void wrap(async () => {
|
||||
setBusyId(user.id);
|
||||
try {
|
||||
await api.users.update(user.id, { status: 'inactive' });
|
||||
setNotice(`Akun "${user.user_name}" dinonaktifkan.`);
|
||||
await reload();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onResetPassword = (id: number) => {
|
||||
void wrap(async () => {
|
||||
setBusyId(id);
|
||||
try {
|
||||
await api.users.setPassword(id, resetPassword);
|
||||
setNotice('Password direset. Bagikan password baru secara aman (di luar sistem).');
|
||||
setResetId(null);
|
||||
setResetPassword('');
|
||||
await reload();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Manajemen Akun"
|
||||
subtitle="Buat, ubah status, nonaktifkan, dan reset password pengguna executive."
|
||||
/>
|
||||
{error && <Banner kind="error">{error}</Banner>}
|
||||
{notice && <Banner kind="success">{notice}</Banner>}
|
||||
|
||||
<section className="bg-white rounded-xl shadow-sm p-5 space-y-4">
|
||||
<h2 className="text-base font-semibold text-gray-900">Buat pengguna baru</h2>
|
||||
<form onSubmit={onCreate} className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-gray-600">Username</span>
|
||||
<input
|
||||
className={inputClass}
|
||||
value={userName}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
required
|
||||
maxLength={30}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-gray-600">Role</span>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as UserStatus)}
|
||||
>
|
||||
{ASSIGNABLE_STATUSES.filter((s) => s !== 'inactive').map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{ROLE_LABELS[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-gray-600">Password sementara</span>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full rounded-md bg-red-600 text-white text-sm font-medium px-4 py-2 hover:bg-red-700 disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Menyimpan…' : 'Buat akun'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="text-xs text-gray-500">
|
||||
Password tidak dikirim lewat email. Bagikan secara terpisah ke pengguna.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 className="text-base font-semibold text-gray-900 mb-4">Daftar pengguna</h2>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b">
|
||||
<th className="py-2 pr-4 font-medium">Username</th>
|
||||
<th className="py-2 pr-4 font-medium">Role</th>
|
||||
<th className="py-2 pr-4 font-medium">Managed by</th>
|
||||
<th className="py-2 font-medium">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => {
|
||||
const isSelf = currentUser?.id === user.id;
|
||||
return (
|
||||
<tr key={user.id} className="border-b border-gray-100 align-top">
|
||||
<td className="py-3 pr-4 font-medium text-gray-900">
|
||||
{user.user_name}
|
||||
{isSelf ? (
|
||||
<span className="ml-2 text-xs font-normal text-gray-500">(Anda)</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{!isSelf && editId === user.id ? (
|
||||
<select
|
||||
className={inputClass}
|
||||
value={editStatus}
|
||||
onChange={(e) => setEditStatus(e.target.value as UserStatus)}
|
||||
>
|
||||
{ASSIGNABLE_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{ROLE_LABELS[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
ROLE_LABELS[user.status] ?? user.status
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-gray-600">
|
||||
{user.managed_by_name ?? '—'}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
{!isSelf && editId === user.id ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === user.id}
|
||||
onClick={() => onSaveStatus(user.id)}
|
||||
className="rounded-md bg-red-600 text-white text-xs px-3 py-1.5 hover:bg-red-700 disabled:opacity-60"
|
||||
>
|
||||
Simpan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditId(null)}
|
||||
className="rounded-md border border-gray-300 text-xs px-3 py-1.5"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{!isSelf && editId !== user.id ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditId(user.id);
|
||||
setEditStatus(user.status);
|
||||
setResetId(null);
|
||||
}}
|
||||
className="rounded-md border border-gray-300 text-xs px-3 py-1.5"
|
||||
>
|
||||
Ubah role
|
||||
</button>
|
||||
) : null}
|
||||
{!isSelf && user.status !== 'inactive' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === user.id}
|
||||
onClick={() => onDeactivate(user)}
|
||||
className="rounded-md border border-gray-300 text-xs px-3 py-1.5 text-red-700"
|
||||
>
|
||||
Nonaktifkan
|
||||
</button>
|
||||
) : null}
|
||||
{isSelf ? (
|
||||
<span className="text-xs text-gray-500 self-center">
|
||||
Role & status akun sendiri tidak bisa diubah di sini.
|
||||
</span>
|
||||
) : null}
|
||||
{resetId === user.id ? (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<input
|
||||
className={`${inputClass} w-40`}
|
||||
type="password"
|
||||
placeholder="Password baru"
|
||||
value={resetPassword}
|
||||
onChange={(e) => setResetPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === user.id || !resetPassword.trim()}
|
||||
onClick={() => onResetPassword(user.id)}
|
||||
className="rounded-md bg-red-600 text-white text-xs px-3 py-1.5 hover:bg-red-700 disabled:opacity-60"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setResetId(null);
|
||||
setResetPassword('');
|
||||
}}
|
||||
className="rounded-md border border-gray-300 text-xs px-3 py-1.5"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setResetId(user.id);
|
||||
setResetPassword('');
|
||||
setEditId(null);
|
||||
}}
|
||||
className="rounded-md border border-gray-300 text-xs px-3 py-1.5"
|
||||
>
|
||||
Reset password
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{users.length === 0 && (
|
||||
<p className="text-sm text-gray-500 py-4">Belum ada pengguna.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagementPage;
|
||||
@@ -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 docWeight = selectedCycle?.chick_in_weight ?? 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} docWeight={docWeight} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<DailyWeightProgressionChart rows={chartRows} docWeight={docWeight} />
|
||||
</div>
|
||||
</div>
|
||||
<DailyWeightGainChart rows={chartRows} docWeight={docWeight} />
|
||||
</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} docWeight={docWeight} />
|
||||
|
||||
{/* 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,32 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import AccountAdminShell from '../AccountAdminShell.tsx';
|
||||
|
||||
vi.mock('../../context/AuthContext.tsx', () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: 9,
|
||||
user_name: 'account_admin',
|
||||
status: 'account_admin',
|
||||
is_superuser: false,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../UserManagementPage.tsx', () => ({
|
||||
default: () => <div>User management content</div>,
|
||||
}));
|
||||
|
||||
describe('AccountAdminShell', () => {
|
||||
it('shows only User Management nav, not ops dashboards', () => {
|
||||
render(<AccountAdminShell />);
|
||||
expect(screen.getByText('Manajemen Akun')).toBeInTheDocument();
|
||||
expect(screen.getByText('User management content')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Hitung Ayam')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('FCR')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { api } from '../../services/apiClient.ts';
|
||||
import FeedSackCountingPage from '../FeedSackCountingPage.tsx';
|
||||
|
||||
const { karungRows, manualRows, cycle, mismatchedKarungRows, mismatchedManualRows } = 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: 'gm',
|
||||
feed_initial_balance: 0,
|
||||
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: '',
|
||||
},
|
||||
];
|
||||
// IoT already on the newer day; manual only exists for the previous day.
|
||||
const mismatchedKarungRows = [
|
||||
{
|
||||
id: 10,
|
||||
cycle: 1,
|
||||
kandang: 1,
|
||||
flock_id: 1,
|
||||
date: '2026-09-02',
|
||||
in_today: 192,
|
||||
out_today: 1,
|
||||
in_total: 192,
|
||||
out_total: 1,
|
||||
feed_use_today: 7,
|
||||
feed_use_total: 7,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
cycle: 1,
|
||||
kandang: 1,
|
||||
flock_id: 1,
|
||||
date: '2026-09-03',
|
||||
in_today: 50,
|
||||
out_today: 0,
|
||||
in_total: 242,
|
||||
out_total: 1,
|
||||
feed_use_today: 4,
|
||||
feed_use_total: 11,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
];
|
||||
const mismatchedManualRows = [
|
||||
{
|
||||
id: 10,
|
||||
cycle: 1,
|
||||
kandang: 1,
|
||||
flock_id: 1,
|
||||
date: '2026-09-02',
|
||||
age_manual: 0,
|
||||
mortality_manual: 0,
|
||||
mortality_manual_total: 0,
|
||||
feed_in_manual: 192,
|
||||
feed_use_manual: 7,
|
||||
feed_in_manual_total: 192,
|
||||
feed_use_manual_total: 7,
|
||||
feed_out_manual: 1,
|
||||
feed_out_manual_total: 1,
|
||||
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, mismatchedKarungRows, mismatchedManualRows };
|
||||
});
|
||||
|
||||
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(),
|
||||
updateCycle: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
describe('FeedSackCountingPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.karungs.list).mockResolvedValue(karungRows);
|
||||
vi.mocked(api.manualInputs.list).mockResolvedValue(manualRows);
|
||||
vi.mocked(api.insights.list).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not reuse yesterday manual values on the latest IoT day stats cards', async () => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
|
||||
vi.mocked(api.karungs.list).mockResolvedValue(mismatchedKarungRows);
|
||||
vi.mocked(api.manualInputs.list).mockResolvedValue(mismatchedManualRows);
|
||||
try {
|
||||
render(<FeedSackCountingPage onSettings={() => undefined} />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Hitung Karung' })).toBeInTheDocument();
|
||||
// Latest IoT day still shows.
|
||||
expect(screen.getAllByText('50').length).toBeGreaterThan(0);
|
||||
// Summary Manual cards show 0 when the latest IoT day has no matching manual row.
|
||||
expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(6);
|
||||
// Daily table still keeps the real 2 Sep manual row across masuk/tuang/keluar boxes.
|
||||
expect(screen.getAllByText('192').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('3 Sep 2026').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('2 Sep 2026').length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import SettingsPage from '../SettingsPage.tsx';
|
||||
|
||||
const {
|
||||
listMock,
|
||||
createMock,
|
||||
removeMock,
|
||||
usersListMock,
|
||||
buhsMock,
|
||||
pendingMock,
|
||||
requestMock,
|
||||
approveMock,
|
||||
refreshUserMock,
|
||||
authState,
|
||||
} = vi.hoisted(() => ({
|
||||
listMock: vi.fn(),
|
||||
createMock: vi.fn(),
|
||||
removeMock: vi.fn(),
|
||||
usersListMock: vi.fn(),
|
||||
buhsMock: vi.fn(),
|
||||
pendingMock: vi.fn(),
|
||||
requestMock: vi.fn(),
|
||||
approveMock: vi.fn(),
|
||||
refreshUserMock: vi.fn(),
|
||||
authState: {
|
||||
user: {
|
||||
id: 5,
|
||||
user_name: 'gm.user',
|
||||
status: 'gm' as string,
|
||||
managed_by: null as number | null,
|
||||
managed_by_name: null as string | null,
|
||||
managed_by_pending: null as number | null,
|
||||
managed_by_pending_name: null as string | null,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/apiClient.ts', () => ({
|
||||
api: {
|
||||
citySites: {
|
||||
list: listMock,
|
||||
create: createMock,
|
||||
remove: removeMock,
|
||||
},
|
||||
users: {
|
||||
list: usersListMock,
|
||||
listBuhs: buhsMock,
|
||||
listPendingManaged: pendingMock,
|
||||
requestManagedBy: requestMock,
|
||||
cancelManagedByRequest: vi.fn(),
|
||||
approveManagedBy: approveMock,
|
||||
rejectManagedBy: vi.fn(),
|
||||
},
|
||||
},
|
||||
errorMessage: (err: unknown) => (err instanceof Error ? err.message : 'Terjadi kesalahan'),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/AuthContext.tsx', () => ({
|
||||
useAuth: () => ({
|
||||
user: authState.user,
|
||||
refreshUser: refreshUserMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/FarmContext.tsx', () => ({
|
||||
useFarm: () => ({
|
||||
reload: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('SettingsPage city site registry', () => {
|
||||
beforeEach(() => {
|
||||
listMock.mockReset();
|
||||
createMock.mockReset();
|
||||
removeMock.mockReset();
|
||||
usersListMock.mockReset();
|
||||
buhsMock.mockReset();
|
||||
pendingMock.mockReset();
|
||||
requestMock.mockReset();
|
||||
approveMock.mockReset();
|
||||
refreshUserMock.mockReset();
|
||||
authState.user = {
|
||||
id: 5,
|
||||
user_name: 'gm.user',
|
||||
status: 'gm',
|
||||
managed_by: null,
|
||||
managed_by_name: null,
|
||||
managed_by_pending: null,
|
||||
managed_by_pending_name: null,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
};
|
||||
refreshUserMock.mockResolvedValue(authState.user);
|
||||
listMock.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
code: 'sukawarna',
|
||||
name: 'Sukawarna',
|
||||
api_base_url: 'https://sukawarna.example/api/v1',
|
||||
has_api_key: true,
|
||||
registered_by: 5,
|
||||
registered_by_name: 'gm.user',
|
||||
is_active: true,
|
||||
last_synced_at: null,
|
||||
last_sync_status: '',
|
||||
last_sync_message: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
]);
|
||||
createMock.mockResolvedValue({
|
||||
id: 2,
|
||||
code: 'cikupa',
|
||||
name: 'Cikupa',
|
||||
api_base_url: 'https://cikupa.example/api/v1',
|
||||
has_api_key: false,
|
||||
registered_by: 5,
|
||||
registered_by_name: 'gm.user',
|
||||
is_active: true,
|
||||
last_synced_at: null,
|
||||
last_sync_status: '',
|
||||
last_sync_message: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
});
|
||||
buhsMock.mockResolvedValue([
|
||||
{ id: 2, user_name: 'buh', status: 'buh', created_at: '', updated_at: '' },
|
||||
]);
|
||||
usersListMock.mockResolvedValue([
|
||||
{
|
||||
id: 5,
|
||||
user_name: 'gm',
|
||||
status: 'gm',
|
||||
managed_by: 2,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
]);
|
||||
pendingMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('lists registered city sites for GM', async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
expect(await screen.findByText('Sukawarna')).toBeInTheDocument();
|
||||
expect(screen.getByText('BUH pengelola')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits a new city site to the view list', async () => {
|
||||
render(<SettingsPage />);
|
||||
await screen.findByText('Sukawarna');
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Contoh: Sukawarna'), {
|
||||
target: { value: 'Cikupa' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('https://site.example/api/v1'), {
|
||||
target: { value: 'https://cikupa.example/api/v1' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tambah ke daftar' }));
|
||||
|
||||
await waitFor(() => expect(createMock).toHaveBeenCalledTimes(1));
|
||||
expect(createMock).toHaveBeenCalledWith({
|
||||
code: 'cikupa',
|
||||
name: 'Cikupa',
|
||||
api_base_url: 'https://cikupa.example/api/v1',
|
||||
is_active: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets GM request a BUH', async () => {
|
||||
requestMock.mockResolvedValue({
|
||||
...authState.user,
|
||||
managed_by_pending: 2,
|
||||
managed_by_pending_name: 'buh',
|
||||
});
|
||||
render(<SettingsPage />);
|
||||
await screen.findByText('BUH pengelola');
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Pilih BUH/i), { target: { value: '2' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Kirim permintaan' }));
|
||||
|
||||
await waitFor(() => expect(requestMock).toHaveBeenCalledWith(2));
|
||||
});
|
||||
|
||||
it('shows pending GM confirmation for BUH', async () => {
|
||||
authState.user = {
|
||||
id: 2,
|
||||
user_name: 'buh',
|
||||
status: 'buh',
|
||||
managed_by: null,
|
||||
managed_by_name: null,
|
||||
managed_by_pending: null,
|
||||
managed_by_pending_name: null,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
};
|
||||
pendingMock.mockResolvedValue([
|
||||
{
|
||||
id: 5,
|
||||
user_name: 'gm.user',
|
||||
status: 'gm',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
]);
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
expect(await screen.findByText('Permintaan GM')).toBeInTheDocument();
|
||||
expect(screen.getByText('gm.user')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Konfirmasi' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import UserManagementPage from '../UserManagementPage.tsx';
|
||||
|
||||
const { listMock, createMock, updateMock, setPasswordMock, authState } = vi.hoisted(() => ({
|
||||
listMock: vi.fn(),
|
||||
createMock: vi.fn(),
|
||||
updateMock: vi.fn(),
|
||||
setPasswordMock: vi.fn(),
|
||||
authState: {
|
||||
user: {
|
||||
id: 99,
|
||||
user_name: 'account_admin',
|
||||
status: 'account_admin' as string,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/apiClient.ts', () => ({
|
||||
api: {
|
||||
users: {
|
||||
list: listMock,
|
||||
create: createMock,
|
||||
update: updateMock,
|
||||
setPassword: setPasswordMock,
|
||||
},
|
||||
},
|
||||
errorMessage: (err: unknown) => (err instanceof Error ? err.message : 'Terjadi kesalahan'),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/AuthContext.tsx', () => ({
|
||||
useAuth: () => ({
|
||||
user: authState.user,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('UserManagementPage', () => {
|
||||
beforeEach(() => {
|
||||
listMock.mockReset();
|
||||
createMock.mockReset();
|
||||
updateMock.mockReset();
|
||||
setPasswordMock.mockReset();
|
||||
authState.user = {
|
||||
id: 99,
|
||||
user_name: 'account_admin',
|
||||
status: 'account_admin',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
};
|
||||
listMock.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
user_name: 'gm1',
|
||||
status: 'gm',
|
||||
managed_by_name: 'buh1',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
{
|
||||
id: 99,
|
||||
user_name: 'account_admin',
|
||||
status: 'account_admin',
|
||||
managed_by_name: null,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
]);
|
||||
createMock.mockResolvedValue({
|
||||
id: 2,
|
||||
user_name: 'newgm',
|
||||
status: 'gm',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
});
|
||||
updateMock.mockResolvedValue({});
|
||||
setPasswordMock.mockResolvedValue({});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('renders list from api.users.list', async () => {
|
||||
render(<UserManagementPage />);
|
||||
expect(await screen.findByText('gm1')).toBeInTheDocument();
|
||||
expect(screen.getByText('buh1')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('GM').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('submits create with password', async () => {
|
||||
render(<UserManagementPage />);
|
||||
await screen.findByText('gm1');
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Username/i), {
|
||||
target: { value: 'newgm' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Password sementara/i), {
|
||||
target: { value: 'temp123' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Buat akun/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMock).toHaveBeenCalledWith({
|
||||
user_name: 'newgm',
|
||||
status: 'gm',
|
||||
password: 'temp123',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('deactivates user', async () => {
|
||||
render(<UserManagementPage />);
|
||||
await screen.findByText('gm1');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Nonaktifkan/i }));
|
||||
await waitFor(() => {
|
||||
expect(updateMock).toHaveBeenCalledWith(1, { status: 'inactive' });
|
||||
});
|
||||
});
|
||||
|
||||
it('hides deactivate and role change on own row', async () => {
|
||||
render(<UserManagementPage />);
|
||||
expect(await screen.findByText('(Anda)')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: /Nonaktifkan/i })).toHaveLength(1);
|
||||
expect(screen.getAllByRole('button', { name: /Ubah role/i })).toHaveLength(1);
|
||||
expect(
|
||||
screen.getByText(/Role & status akun sendiri tidak bisa diubah/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resets password', async () => {
|
||||
render(<UserManagementPage />);
|
||||
await screen.findByText('gm1');
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Reset password/i })[0]);
|
||||
fireEvent.change(screen.getByPlaceholderText(/Password baru/i), {
|
||||
target: { value: 'reset-me' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Reset$/i }));
|
||||
await waitFor(() => {
|
||||
expect(setPasswordMock).toHaveBeenCalledWith(1, 'reset-me');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import type { CountingAccuracyWarning } from '../../utils/countingAccuracyWarning.ts';
|
||||
import { formatNumber } from '../../utils/format.ts';
|
||||
|
||||
type Props = {
|
||||
warning: CountingAccuracyWarning;
|
||||
};
|
||||
|
||||
const CountingAccuracyWarningBanner: React.FC<Props> = ({ warning }) => {
|
||||
const isOver = warning.direction === 'over';
|
||||
const title = isOver
|
||||
? 'Hitung ayam IoT lebih tinggi dari stock manual'
|
||||
: 'Akurasi hitung ayam IoT rendah (IoT lebih rendah dari stock)';
|
||||
const body = isOver
|
||||
? 'Manager perlu cek: kemungkinan fraud / ayam baru yang belum tercatat, atau AI overcount; pastikan juga video kamera lengkap dan tidak bermasalah.'
|
||||
: 'Manager perlu cek: video kamera lengkap atau tidak, kemungkinan masalah AI, atau ada selisih stok.';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-6 rounded-lg border-l-4 border-amber-500 bg-amber-50 p-4 text-amber-950"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<i className="fa-solid fa-triangle-exclamation mt-0.5 text-xl text-amber-600" aria-hidden />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<p className="text-sm">
|
||||
Hari {warning.day} · IoT {formatNumber(warning.iotCount, 0)} vs stock{' '}
|
||||
{formatNumber(warning.stockAkhir, 0)} (akurasi {formatNumber(warning.accuracy, 1)}%)
|
||||
</p>
|
||||
<p className="text-sm">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CountingAccuracyWarningBanner;
|
||||
@@ -0,0 +1,850 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api, errorMessage } from '../../services/apiClient.ts';
|
||||
import type { AIInsight, ChickenCounting, KPI, ManualInput } from '../../types/api.ts';
|
||||
import { useFarm } from '../../context/FarmContext.tsx';
|
||||
import { Banner, EmptyFarm, PageHeader, Spinner } from '../ui/Feedback.tsx';
|
||||
import {
|
||||
addDaysIso,
|
||||
cycleDayForDate,
|
||||
formatDateId,
|
||||
formatDateRange,
|
||||
formatNumber,
|
||||
resolveCycleStatus,
|
||||
todayIso,
|
||||
} from '../../utils/format.ts';
|
||||
import PopulationTrendChart from './PopulationTrendChart.tsx';
|
||||
import CountingAccuracyWarningBanner from './CountingAccuracyWarningBanner.tsx';
|
||||
import { getCountingAccuracyWarning } from '../../utils/countingAccuracyWarning.ts';
|
||||
|
||||
type Row = {
|
||||
day: number;
|
||||
date: string;
|
||||
dateLabel: string;
|
||||
stockAwal: number;
|
||||
mati: number;
|
||||
matiIot: number;
|
||||
akurasiMati: number;
|
||||
panen: number;
|
||||
beratPanen: number;
|
||||
stockAkhir: number;
|
||||
chickenCount?: number;
|
||||
persentase: number;
|
||||
mortalityTotal: number;
|
||||
harvestTotal: number;
|
||||
harvestWeightTotal: number;
|
||||
isEdited: boolean;
|
||||
isToday: boolean;
|
||||
isFuture: boolean;
|
||||
};
|
||||
|
||||
function mortalityAccuracy(manual: number, iot: number): number {
|
||||
if (manual === 0 && iot === 0) return 100;
|
||||
const denom = Math.max(manual, iot);
|
||||
if (denom <= 0) return 100;
|
||||
return Math.round((Math.min(manual, iot) / denom) * 1000) / 10;
|
||||
}
|
||||
|
||||
const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
|
||||
const [kpis, setKpis] = useState<KPI[]>([]);
|
||||
const [countings, setCountings] = useState<ChickenCounting[]>([]);
|
||||
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [searchDay, setSearchDay] = useState<string>('');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
const isClosedCycle = !selectedCycle || resolveCycleStatus(selectedCycle) !== 'active';
|
||||
|
||||
const loadData = useCallback(async (cycleId: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [k, c, m, i] = await Promise.all([
|
||||
api.kpis.list({ cycle_id: cycleId }),
|
||||
api.countings.list({ cycle_id: cycleId }),
|
||||
api.manualInputs.list({ cycle_id: cycleId }),
|
||||
api.insights.list({ cycle_id: cycleId }),
|
||||
]);
|
||||
setKpis(k);
|
||||
setCountings(c);
|
||||
setManualRows(m);
|
||||
setInsights(i);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCycle) {
|
||||
setKpis([]);
|
||||
setCountings([]);
|
||||
setManualRows([]);
|
||||
setInsights([]);
|
||||
return;
|
||||
}
|
||||
void loadData(selectedCycle.id);
|
||||
}, [selectedCycle, loadData]);
|
||||
|
||||
const handleSync = useCallback(async () => {
|
||||
if (!selectedCycle) return;
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await api.countings.sync({ cycle_id: selectedCycle.id });
|
||||
setNotice('Sinkron chicken counting edge berhasil.');
|
||||
await loadData(selectedCycle.id);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [selectedCycle, loadData]);
|
||||
|
||||
// Build daily rows through calendar today (not publish-gated current_day).
|
||||
const rows = useMemo<Row[]>(() => {
|
||||
if (!selectedCycle) return [];
|
||||
|
||||
const countingByDate = new Map(countings.map((c) => [c.date, c]));
|
||||
const manualByDate = new Map(manualRows.map((m) => [m.date, m]));
|
||||
const kpiByDate = new Map(kpis.map((k) => [k.date, k]));
|
||||
|
||||
const lastCycleDay = Math.max(0, (selectedCycle.total_days || 1) - 1);
|
||||
const endDay = cycleDayForDate(selectedCycle.start_date, selectedCycle.end_date);
|
||||
const calendarDay = cycleDayForDate(selectedCycle.start_date, todayIso());
|
||||
const maxDay = Math.min(calendarDay, lastCycleDay, endDay);
|
||||
const today = todayIso();
|
||||
|
||||
let previousStockAkhir = 0;
|
||||
let cumulativeMortality = 0;
|
||||
let cumulativeHarvest = 0;
|
||||
let cumulativeHarvestWeight = 0;
|
||||
const result: Row[] = [];
|
||||
|
||||
for (let day = 0; day <= maxDay; day++) {
|
||||
const date = addDaysIso(selectedCycle.start_date, day);
|
||||
const kpi = kpiByDate.get(date);
|
||||
const manual = manualByDate.get(date);
|
||||
const counting = countingByDate.get(date);
|
||||
|
||||
const stockAwal = day === 0 ? selectedCycle.doc_in_count || 0 : previousStockAkhir;
|
||||
const mati = manual?.mortality_manual ?? kpi?.mortality ?? 0;
|
||||
const matiIot = counting?.mortality_count ?? 0;
|
||||
const akurasiMati = mortalityAccuracy(mati, matiIot);
|
||||
const panen = manual?.harvest_manual ?? kpi?.harvest ?? 0;
|
||||
// Database menyimpan berat panen dalam gram; tampilkan selalu dalam kg.
|
||||
const beratPanen = (manual?.harvest_weight_manual ?? kpi?.harvest_weight ?? 0) / 1000;
|
||||
const stockAkhir = kpi?.chicken_life ?? Math.max(0, stockAwal - mati - panen);
|
||||
|
||||
const chickenCount = counting?.total_count;
|
||||
// Akurasi Chicken Counting (IOT) terhadap stock akhir manual:
|
||||
// (nilai kecil / nilai besar) × 100%. Tanpa data IOT, tetap 0 (tampil "-").
|
||||
const persentase =
|
||||
chickenCount !== undefined && chickenCount !== null && stockAkhir > 0
|
||||
? (Math.min(chickenCount, stockAkhir) / Math.max(chickenCount, stockAkhir)) * 100
|
||||
: 0;
|
||||
|
||||
cumulativeMortality += mati;
|
||||
cumulativeHarvest += panen;
|
||||
cumulativeHarvestWeight += beratPanen;
|
||||
|
||||
result.push({
|
||||
day,
|
||||
date,
|
||||
dateLabel: formatDateId(date),
|
||||
stockAwal,
|
||||
mati,
|
||||
matiIot,
|
||||
akurasiMati,
|
||||
panen,
|
||||
beratPanen,
|
||||
stockAkhir,
|
||||
chickenCount,
|
||||
persentase: Math.round(persentase * 10) / 10,
|
||||
mortalityTotal: kpi?.mortality_total ?? cumulativeMortality,
|
||||
harvestTotal: kpi?.harvest_total ?? cumulativeHarvest,
|
||||
harvestWeightTotal:
|
||||
kpi?.harvest_weight_total !== undefined
|
||||
? kpi.harvest_weight_total / 1000
|
||||
: cumulativeHarvestWeight,
|
||||
isEdited: !!manual,
|
||||
isToday: date === today,
|
||||
isFuture: date > today,
|
||||
});
|
||||
|
||||
previousStockAkhir = stockAkhir;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [kpis, countings, manualRows, selectedCycle]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const initialPopulation = selectedCycle?.doc_in_count || 0;
|
||||
const latest = rows[rows.length - 1];
|
||||
const totalMortality = latest?.mortalityTotal ?? 0;
|
||||
const totalPanen = latest?.harvestTotal ?? 0;
|
||||
const totalBeratPanen = latest?.harvestWeightTotal ?? 0;
|
||||
const currentPopulation = latest?.stockAkhir ?? initialPopulation;
|
||||
const mortalityRate = initialPopulation
|
||||
? ((totalMortality / initialPopulation) * 100).toFixed(2)
|
||||
: '0.00';
|
||||
|
||||
return {
|
||||
initialPopulation,
|
||||
totalMortality,
|
||||
currentPopulation,
|
||||
totalPanen,
|
||||
totalBeratPanen,
|
||||
mortalityRate,
|
||||
};
|
||||
}, [rows, selectedCycle]);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
rows.map((r) => ({
|
||||
date: r.dateLabel,
|
||||
population: r.stockAkhir,
|
||||
chickenCount: r.chickenCount ?? undefined,
|
||||
})),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const countingAccuracyWarning = useMemo(() => {
|
||||
if (!selectedCycle) return null;
|
||||
return getCountingAccuracyWarning({
|
||||
cycle: selectedCycle,
|
||||
countings,
|
||||
kpis,
|
||||
manualInputs: manualRows,
|
||||
});
|
||||
}, [selectedCycle, countings, kpis, manualRows]);
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
let filtered = rows;
|
||||
if (searchDay) {
|
||||
const searchNum = parseInt(searchDay, 10);
|
||||
if (!Number.isNaN(searchNum)) {
|
||||
filtered = filtered.filter((r) => r.day === searchNum);
|
||||
}
|
||||
}
|
||||
return [...filtered].sort((a, b) => (sortOrder === 'asc' ? a.day - b.day : b.day - a.day));
|
||||
}, [rows, searchDay, sortOrder]);
|
||||
|
||||
const getPersentaseBadge = (persentase: number) => {
|
||||
if (!persentase) return <span className="text-gray-400">-</span>;
|
||||
|
||||
const isGreen = persentase >= 95;
|
||||
const isYellow = persentase >= 90 && persentase < 95;
|
||||
const colorClass = isGreen
|
||||
? 'bg-green-100 text-green-700'
|
||||
: isYellow
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-red-100 text-red-700';
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
|
||||
{persentase.toFixed(1)}%
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getAkurasiMatiBadge = (akurasi: number) => {
|
||||
const isGreen = akurasi >= 95;
|
||||
const isYellow = akurasi >= 90 && akurasi < 95;
|
||||
const colorClass = isGreen
|
||||
? 'bg-green-100 text-green-700'
|
||||
: isYellow
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-red-100 text-red-700';
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
|
||||
{akurasi.toFixed(1)}%
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Hitung Ayam"
|
||||
icon={<i className="fa-solid fa-calculator" />}
|
||||
subtitle={`Lihat data deplesi harian untuk siklus produksi · ${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSync}
|
||||
disabled={syncing || isClosedCycle}
|
||||
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
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{countingAccuracyWarning && (
|
||||
<CountingAccuracyWarningBanner warning={countingAccuracyWarning} />
|
||||
)}
|
||||
|
||||
{/* AI Insight */}
|
||||
<section className="mb-6">
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-wand-magic-sparkles text-purple-500 mr-2"></i>
|
||||
AI Insight
|
||||
</h2>
|
||||
{insights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{insights.slice(0, 4).map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">{item.section}</p>
|
||||
<h3 className="font-semibold text-gray-800 mt-1">{item.alert || 'Insight'}</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{item.session}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
||||
{item.insight_text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Population Chart */}
|
||||
<div className="mb-6">
|
||||
<PopulationTrendChart
|
||||
data={chartData}
|
||||
title={`Tren Populasi - ${selectedKandang?.kandang_name || ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<Banner kind="error">{error}</Banner>
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="mb-4">
|
||||
<Banner kind="success">{notice}</Banner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-blue-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 mb-1">Populasi Awal</p>
|
||||
<p className="text-xl font-bold text-gray-800">
|
||||
{formatNumber(stats.initialPopulation, 0)}
|
||||
</p>
|
||||
</div>
|
||||
<i className="fa-solid fa-egg text-blue-500 text-2xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-green-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 mb-1">Populasi Saat Ini</p>
|
||||
<p className="text-xl font-bold text-green-600">
|
||||
{formatNumber(stats.currentPopulation, 0)}
|
||||
</p>
|
||||
</div>
|
||||
<i className="fa-solid fa-drumstick-bite text-green-500 text-2xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-red-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 mb-1">Total Mortalitas</p>
|
||||
<p className="text-xl font-bold text-red-600">
|
||||
{formatNumber(stats.totalMortality, 0)}
|
||||
</p>
|
||||
</div>
|
||||
<i className="fa-solid fa-heart-crack text-red-500 text-2xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-purple-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 mb-1">Total Panen</p>
|
||||
<p className="text-xl font-bold text-purple-700">
|
||||
{formatNumber(stats.totalPanen, 0)} ekor
|
||||
</p>
|
||||
<p className="text-sm text-purple-600 mt-1">
|
||||
{formatNumber(stats.totalBeratPanen, 2)} kg
|
||||
</p>
|
||||
</div>
|
||||
<i className="fa-solid fa-box text-purple-500 text-2xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-4 border-l-4 border-orange-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 mb-1">Tingkat Mortalitas</p>
|
||||
<p className="text-xl font-bold text-orange-600">{stats.mortalityRate}%</p>
|
||||
</div>
|
||||
<i className="fa-solid fa-chart-line text-orange-500 text-2xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
{/* Filters and Sorting */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<i className="fa-solid fa-search mr-2"></i>Cari Hari
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={searchDay}
|
||||
onChange={(e) => setSearchDay(e.target.value)}
|
||||
placeholder="Masukkan nomor hari..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSortOrder('asc')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
sortOrder === 'asc'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<i className="fa-solid fa-arrow-up mr-2"></i>
|
||||
Terlama
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSortOrder('desc')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
sortOrder === 'desc'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<i className="fa-solid fa-arrow-down mr-2"></i>
|
||||
Terbaru
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isClosedCycle && (
|
||||
<div className="mb-6 bg-amber-50 border-l-4 border-amber-400 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<i className="fa-solid fa-eye text-amber-500 text-xl mr-4"></i>
|
||||
<div>
|
||||
<p className="text-sm text-amber-800 font-semibold">
|
||||
Mode Lihat Saja (Siklus Selesai)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="space-y-4 p-4 md:hidden">
|
||||
{filteredRecords.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-gray-200 bg-gray-50 px-6 py-12 text-center text-gray-500">
|
||||
<i className="fa-solid fa-inbox text-4xl mb-3 block text-gray-300"></i>
|
||||
Tidak ada data yang ditemukan
|
||||
</div>
|
||||
) : (
|
||||
filteredRecords.map((record) => {
|
||||
const cardTone = record.isToday
|
||||
? 'ring-2 ring-blue-200 bg-blue-50/40'
|
||||
: record.panen > 0
|
||||
? 'ring-1 ring-purple-200 bg-white'
|
||||
: 'bg-white';
|
||||
|
||||
return (
|
||||
<article
|
||||
key={record.day}
|
||||
className={`rounded-2xl border border-gray-200 shadow-sm overflow-hidden ${cardTone}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 px-4 py-4 bg-gray-50/80">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-600 px-3 py-1 text-xs font-semibold text-white">
|
||||
Hari {record.day}
|
||||
</span>
|
||||
{record.isToday && (
|
||||
<span className="rounded-full bg-blue-100 px-2.5 py-1 text-[11px] font-semibold text-blue-700">
|
||||
Hari ini
|
||||
</span>
|
||||
)}
|
||||
{record.isFuture && (
|
||||
<span className="rounded-full bg-gray-200 px-2.5 py-1 text-[11px] font-semibold text-gray-700">
|
||||
Mendatang
|
||||
</span>
|
||||
)}
|
||||
{record.isEdited && (
|
||||
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-[11px] font-semibold text-amber-700">
|
||||
Diubah
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium text-gray-700">
|
||||
{record.dateLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl bg-slate-50 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
Stock awal
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-slate-800">
|
||||
{formatNumber(record.stockAwal, 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-red-50 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-red-500">
|
||||
Mati
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold text-red-600 md:text-sm">
|
||||
{formatNumber(record.mati, 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-rose-50 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-rose-500">
|
||||
Mati IoT
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold text-rose-600 md:text-sm">
|
||||
{formatNumber(record.matiIot, 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-emerald-600">
|
||||
Akurasi Mati
|
||||
</div>
|
||||
<div className="mt-1">{getAkurasiMatiBadge(record.akurasiMati)}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-purple-50 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-purple-500">
|
||||
Panen
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold text-purple-600 md:text-sm">
|
||||
{formatNumber(record.panen, 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-purple-50 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-purple-500">
|
||||
Berat panen (kg)
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold text-purple-600 md:text-sm">
|
||||
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'} kg
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-slate-50 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
Stock akhir
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold text-slate-900 md:text-sm">
|
||||
{formatNumber(record.stockAkhir, 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-blue-500">
|
||||
Chicken Counting
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold text-blue-600 md:text-sm">
|
||||
{record.chickenCount !== undefined
|
||||
? formatNumber(record.chickenCount, 0)
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-emerald-600">
|
||||
Persentase
|
||||
</div>
|
||||
<div className="mt-1">{getPersentaseBadge(record.persentase)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-[1280px] lg:min-w-[1380px] w-full border-collapse table-auto">
|
||||
<thead className="bg-gray-50">
|
||||
{/* Top header row with grouped columns */}
|
||||
<tr className="border-b border-gray-300">
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="w-16 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 border-r border-gray-300"
|
||||
>
|
||||
Hari
|
||||
</th>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="w-24 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 border-r border-gray-300"
|
||||
>
|
||||
Tanggal
|
||||
</th>
|
||||
<th
|
||||
colSpan={4}
|
||||
className="px-2 py-2 text-center text-xs font-bold uppercase text-white bg-blue-400 border-r border-gray-300"
|
||||
>
|
||||
Deplesi Daily
|
||||
</th>
|
||||
<th
|
||||
colSpan={2}
|
||||
className="px-2 py-2 text-center text-xs font-bold uppercase text-white bg-purple-400 border-r border-gray-300"
|
||||
>
|
||||
PANEN
|
||||
</th>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="w-24 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 border-r border-gray-300"
|
||||
>
|
||||
Stock akhir
|
||||
</th>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="w-28 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 border-r border-gray-300"
|
||||
>
|
||||
Chicken Counting (IOT)
|
||||
</th>
|
||||
<th
|
||||
rowSpan={2}
|
||||
className="w-20 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700"
|
||||
>
|
||||
Persentase
|
||||
</th>
|
||||
</tr>
|
||||
{/* Second header row with individual column names */}
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="w-24 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-blue-50 border-r border-gray-300">
|
||||
Stock awal
|
||||
</th>
|
||||
<th className="w-16 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-blue-50 border-r border-gray-300">
|
||||
Mati
|
||||
</th>
|
||||
<th className="w-16 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-blue-50 border-r border-gray-300">
|
||||
Mati IoT
|
||||
</th>
|
||||
<th className="w-20 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-blue-50 border-r border-gray-300">
|
||||
Akurasi Mati
|
||||
</th>
|
||||
<th className="w-20 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-purple-50 border-r border-gray-300">
|
||||
JUMLAH PANEN
|
||||
</th>
|
||||
<th className="w-24 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-purple-50 border-r border-gray-300">
|
||||
BERAT PANEN (KG)
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{filteredRecords.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={11}
|
||||
className="px-6 py-12 text-center text-gray-500"
|
||||
>
|
||||
<i className="fa-solid fa-inbox text-4xl mb-3 block text-gray-300"></i>
|
||||
Tidak ada data yang ditemukan
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredRecords.map((record, index) => {
|
||||
const rowClass = `hover:bg-gray-100 transition-colors ${
|
||||
index % 2 === 0 ? 'bg-white' : 'bg-gray-50'
|
||||
} ${record.panen > 0 ? 'border-l-4 border-purple-400' : ''}`;
|
||||
|
||||
return (
|
||||
<tr key={record.day} className={rowClass}>
|
||||
{/* Hari */}
|
||||
<td className="px-2 py-2 whitespace-nowrap border-r border-gray-200 text-center">
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 text-xs font-bold text-blue-800 md:h-9 md:w-9 md:text-sm">
|
||||
{record.day}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Tanggal */}
|
||||
<td className="px-2 py-2 whitespace-nowrap border-r border-gray-200 text-center text-[11px] text-gray-700 md:text-sm">
|
||||
{record.dateLabel}
|
||||
</td>
|
||||
|
||||
{/* Stock awal */}
|
||||
<td className="px-2 py-2 whitespace-nowrap border-r border-gray-200 text-center text-[11px] text-gray-500 md:text-sm">
|
||||
{formatNumber(record.stockAwal, 0)}
|
||||
</td>
|
||||
|
||||
{/* Mati */}
|
||||
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
||||
<span className="text-[11px] font-semibold text-red-600 md:text-sm">
|
||||
{formatNumber(record.mati, 0)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Mati IoT */}
|
||||
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
||||
<span className="text-[11px] font-semibold text-rose-600 md:text-sm">
|
||||
{formatNumber(record.matiIot, 0)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Akurasi Mati */}
|
||||
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
||||
{getAkurasiMatiBadge(record.akurasiMati)}
|
||||
</td>
|
||||
|
||||
{/* JUMLAH PANEN */}
|
||||
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
||||
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
|
||||
{formatNumber(record.panen, 0)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* BERAT PANEN */}
|
||||
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
||||
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
|
||||
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Stock akhir */}
|
||||
<td className="px-2 py-2 whitespace-nowrap text-center text-[11px] font-bold text-gray-800 border-r border-gray-200 md:text-sm">
|
||||
{formatNumber(record.stockAkhir, 0)}
|
||||
</td>
|
||||
|
||||
{/* Chicken Counting */}
|
||||
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
||||
<span className="text-[11px] font-semibold text-blue-600 md:text-sm">
|
||||
{record.chickenCount !== undefined
|
||||
? formatNumber(record.chickenCount, 0)
|
||||
: '-'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Persentase */}
|
||||
<td className="px-2 py-2 whitespace-nowrap text-center">
|
||||
{getPersentaseBadge(record.persentase)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-6 bg-blue-50 border-l-4 border-blue-500 rounded-lg p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<i className="fa-solid fa-info-circle text-blue-500 text-2xl"></i>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h3 className="text-sm font-semibold text-blue-800 mb-2">Informasi</h3>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>
|
||||
<i className="fa-solid fa-circle text-xs mr-2"></i>Data hanya ditampilkan hingga
|
||||
hari ini (dihitung dari tanggal mulai siklus)
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-circle text-xs mr-2"></i>
|
||||
<strong>Hari 0:</strong> Stock awal = Jumlah DOC In (ayam masuk)
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-circle text-xs mr-2"></i>
|
||||
<strong>Hari 1+:</strong> Stock awal = Stock akhir hari sebelumnya
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-circle text-xs mr-2"></i>
|
||||
<strong>Stock akhir</strong> = Stock awal - Mati - panen (otomatis terhitung)
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-circle text-xs mr-2"></i>
|
||||
<strong>Mati:</strong> Mortalitas harian manual | <strong>Mati IoT:</strong>{' '}
|
||||
mortalitas dari edge API (kosong = 0)
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-circle text-xs mr-2"></i>
|
||||
<strong>Akurasi Mati</strong> = (nilai kecil / nilai besar) × 100% antara Mati
|
||||
manual dan Mati IoT (keduanya 0 → 100%)
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-circle text-xs mr-2"></i>
|
||||
<strong>panen:</strong> Ayam dipanen
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-circle text-xs mr-2"></i>
|
||||
<strong>Persentase</strong> = (nilai kecil / nilai besar) × 100% untuk konsep
|
||||
akurasi hitungan IOT terhadap stock akhir manual
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-circle text-xs mr-2"></i>
|
||||
<strong>Chicken Counting (IOT)</strong> = hasil hitung otomatis dari IOT
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-circle text-xs mr-2"></i>Baris dengan border ungu
|
||||
menandakan ada panen pada hari tersebut
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataManagementPage;
|
||||
@@ -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,36 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import CountingAccuracyWarningBanner from '../CountingAccuracyWarningBanner.tsx';
|
||||
import type { CountingAccuracyWarning } from '../../../utils/countingAccuracyWarning.ts';
|
||||
|
||||
const base: CountingAccuracyWarning = {
|
||||
day: 2,
|
||||
date: '2026-08-12',
|
||||
iotCount: 900,
|
||||
stockAkhir: 1000,
|
||||
accuracy: 90,
|
||||
ratioPercent: 90,
|
||||
direction: 'under',
|
||||
};
|
||||
|
||||
describe('CountingAccuracyWarningBanner', () => {
|
||||
it('renders under-count manager checklist', () => {
|
||||
render(<CountingAccuracyWarningBanner warning={base} />);
|
||||
expect(screen.getByText(/IoT lebih rendah dari stock/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/video kamera lengkap/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Hari 2/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/90%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders over-count fraud / AI checklist', () => {
|
||||
render(
|
||||
<CountingAccuracyWarningBanner
|
||||
warning={{ ...base, iotCount: 1060, stockAkhir: 1000, accuracy: 94.3, ratioPercent: 106, direction: 'over' }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/lebih tinggi dari stock manual/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/fraud/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/akurasi 94,3%/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/106%/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import DataManagementPage from '../DataManagementPage.tsx';
|
||||
import { api } from '../../../services/apiClient.ts';
|
||||
|
||||
vi.mock('../../../services/apiClient.ts', () => ({
|
||||
api: {
|
||||
kpis: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
countings: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
sync: vi.fn().mockResolvedValue({ upserted: 2, dates: ['2026-09-02', '2026-09-03'] }),
|
||||
},
|
||||
manualInputs: {
|
||||
list: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
date: '2026-08-10',
|
||||
mortality_manual: 10,
|
||||
mortality_manual_total: 10,
|
||||
harvest_manual: 0,
|
||||
harvest_weight_manual: 0,
|
||||
},
|
||||
]),
|
||||
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: 1,
|
||||
visible_through_date: '2026-08-10',
|
||||
start_date: '2026-08-10',
|
||||
end_date: '2026-09-13',
|
||||
chick_in_weight: 45,
|
||||
doc_in_count: 20000,
|
||||
doc_in_weight: 45,
|
||||
status: 'gm',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/format.ts', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../utils/format.ts')>(
|
||||
'../../../utils/format.ts'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
todayIso: () => '2026-08-12',
|
||||
};
|
||||
});
|
||||
|
||||
describe('DataManagementPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.kpis.list).mockResolvedValue([]);
|
||||
vi.mocked(api.countings.list).mockResolvedValue([]);
|
||||
vi.mocked(api.countings.sync).mockResolvedValue({
|
||||
upserted: 2,
|
||||
dates: ['2026-09-02', '2026-09-03'],
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
expect(screen.queryByRole('button', { name: /Edit/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Mati IoT as 0 and Akurasi Mati when counting row is missing', async () => {
|
||||
render(<DataManagementPage onSettings={() => undefined} />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Hitung Ayam' })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Mati IoT').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Akurasi Mati').length).toBeGreaterThan(0);
|
||||
// manual 10 vs iot 0 → 0.0%
|
||||
expect(screen.getAllByText('0.0%').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calls chicken counting sync when Sync is clicked', async () => {
|
||||
render(<DataManagementPage onSettings={() => undefined} />);
|
||||
|
||||
await screen.findByRole('heading', { name: 'Hitung Ayam' });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sync/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.countings.sync).toHaveBeenCalledWith({ cycle_id: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
it('shows under-count IoT accuracy warning for the latest IoT day', async () => {
|
||||
vi.mocked(api.kpis.list).mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 1,
|
||||
flock_id: 1,
|
||||
date: '2026-08-12',
|
||||
age: 2,
|
||||
mortality: 0,
|
||||
mortality_total: 10,
|
||||
feed: 0,
|
||||
feed_total: 0,
|
||||
harvest: 0,
|
||||
harvest_total: 0,
|
||||
harvest_weight: 0,
|
||||
harvest_weight_total: 0,
|
||||
chicken_life: 19990,
|
||||
chicken_life_percentage: 99.95,
|
||||
iot_weight: 0,
|
||||
average_harvest_day: 0,
|
||||
average_harvest_weight: 0,
|
||||
fcr: 0,
|
||||
eef: 0,
|
||||
cc: 0,
|
||||
cw: 0,
|
||||
fs: 0,
|
||||
manual: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
]);
|
||||
vi.mocked(api.countings.list).mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
kandang: 1,
|
||||
flock_id: 1,
|
||||
date: '2026-08-12',
|
||||
total_count: 18000,
|
||||
mortality_count: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
]);
|
||||
|
||||
render(<DataManagementPage onSettings={() => undefined} />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(within(alert).getByText(/IoT lebih rendah dari stock/i)).toBeInTheDocument();
|
||||
expect(within(alert).getByText(/Hari 2/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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:bg-slate-400 disabled:hover:bg-slate-400';
|
||||
|
||||
const label = (hasSnapshot: boolean) => (hasSnapshot ? 'Lihat' : 'Buat');
|
||||
|
||||
const DashboardReportCtas: React.FC<DashboardReportCtasProps> = ({
|
||||
cycleClosed,
|
||||
hasDailySnapshot,
|
||||
hasEndCycleSnapshot,
|
||||
onOpenDaily,
|
||||
onOpenEndCycle,
|
||||
}) => (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<button type="button" onClick={onOpenDaily} className={buttonClass}>
|
||||
{label(hasDailySnapshot)} Harian
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenEndCycle}
|
||||
disabled={!cycleClosed}
|
||||
className={buttonClass}
|
||||
>
|
||||
{label(hasEndCycleSnapshot)} Akhir Siklus
|
||||
</button>
|
||||
</div>
|
||||
{!cycleClosed && (
|
||||
<p className="text-xs text-slate-500">Tersedia setelah siklus ditutup</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default DashboardReportCtas;
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { errorMessage } from '../../services/apiClient.ts';
|
||||
import type { View } from '../../types/common.ts';
|
||||
import { useFarm } from '../../context/FarmContext.tsx';
|
||||
import { Banner, Spinner } from '../ui/Feedback.tsx';
|
||||
import { formatRatio } from '../../utils/format.ts';
|
||||
import {
|
||||
loadKandangRanking,
|
||||
type KandangRankingRow,
|
||||
} from '../../utils/kandangRanking.ts';
|
||||
|
||||
type Props = {
|
||||
setView: (view: View) => void;
|
||||
};
|
||||
|
||||
const KandangRankingTop5: React.FC<Props> = ({ setView }) => {
|
||||
const {
|
||||
selectedSite,
|
||||
setSelectedSiteId,
|
||||
setSelectedKandangId,
|
||||
setSelectedCycleId,
|
||||
} = useFarm();
|
||||
const [rows, setRows] = useState<KandangRankingRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const openEef = (row: KandangRankingRow) => {
|
||||
setSelectedSiteId(row.siteId);
|
||||
setSelectedKandangId(row.kandangId);
|
||||
setSelectedCycleId(row.cycleId);
|
||||
setView('eefDashboard');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSite) {
|
||||
setRows([]);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await loadKandangRanking({ sites: [selectedSite], months: 3 });
|
||||
if (!cancelled) setRows(next.slice(0, 5));
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(errorMessage(err));
|
||||
setRows([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedSite]);
|
||||
|
||||
if (!selectedSite) return null;
|
||||
|
||||
return (
|
||||
<section className="mt-8 bg-white rounded-xl shadow-sm border border-gray-100 p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-800">Peringkat EEF</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Top 5 · {selectedSite.site_name} · 3 bulan terakhir (siklus tertutup)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-semibold text-red-600 hover:text-red-700"
|
||||
onClick={() => setView('kandangRanking')}
|
||||
>
|
||||
Lihat semua
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <Banner kind="error">{error}</Banner>}
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Tidak ada siklus tertutup dalam 3 bulan terakhir.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{rows.map((row) => (
|
||||
<li
|
||||
key={row.cycleId}
|
||||
className="py-3 flex items-center justify-between gap-3 cursor-pointer hover:bg-gray-50 -mx-2 px-2 rounded-lg"
|
||||
onClick={() => openEef(row)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="w-7 h-7 rounded-full bg-red-50 text-red-700 text-xs font-bold flex items-center justify-center flex-shrink-0">
|
||||
{row.rank > 0 ? row.rank : '—'}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-800 truncate">{row.kandangName}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
FCR {row.fcr == null ? 'N/A' : formatRatio(row.fcr, 3)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-gray-900 flex-shrink-0">
|
||||
{row.eef == null ? 'N/A' : formatRatio(row.eef, 3)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default KandangRankingTop5;
|
||||
@@ -0,0 +1,639 @@
|
||||
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 { buildReportSummary, formatSummaryValue } from '../../utils/reportSummary.ts';
|
||||
import type { 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 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);
|
||||
|
||||
const latestDay = selectedCycle ? Math.max(0, selectedCycle.current_day) : 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]);
|
||||
|
||||
const summary = useMemo(
|
||||
() => (payload ? buildReportSummary(payload) : null),
|
||||
[payload]
|
||||
);
|
||||
|
||||
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 }}>
|
||||
<strong>Berat DOC:</strong>{' '}
|
||||
{payload.docWeight == null || !Number.isFinite(payload.docWeight)
|
||||
? '—'
|
||||
: `${formatNumber(payload.docWeight, 1, 0, true)} gram`}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>
|
||||
Dicetak: {printedDate}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div
|
||||
data-pdf-block
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
|
||||
gap: 8,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{summary.metrics.map((metric) => {
|
||||
const formattedValue = formatSummaryValue(metric);
|
||||
const isHighlight = metric.key === 'fcr' || metric.key === 'eef';
|
||||
const columnPlacement = isHighlight
|
||||
? ({
|
||||
gridColumn: metric.key === 'fcr' ? 2 : 3,
|
||||
marginTop: 12,
|
||||
} as const)
|
||||
: {};
|
||||
return (
|
||||
<div
|
||||
key={metric.key}
|
||||
style={
|
||||
isHighlight
|
||||
? {
|
||||
border: '2px solid #5b21b6',
|
||||
borderRadius: 10,
|
||||
padding: '12px 12px 14px',
|
||||
background: 'linear-gradient(180deg, #6d28d0 0%, #5b21b6 100%)',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 2px 0 #4c1d95',
|
||||
...columnPlacement,
|
||||
}
|
||||
: {
|
||||
border: '1px solid #e9d5ff',
|
||||
borderTop: '3px solid #7e22ce',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
background: '#faf5ff',
|
||||
...columnPlacement,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: isHighlight ? 11 : 10,
|
||||
color: isHighlight ? '#e9d5ff' : '#6b7280',
|
||||
fontWeight: 700,
|
||||
letterSpacing: isHighlight ? 0.6 : 0,
|
||||
textTransform: isHighlight ? 'uppercase' : 'none',
|
||||
}}
|
||||
>
|
||||
{metric.label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: isHighlight ? 26 : 16,
|
||||
color: isHighlight ? '#ffffff' : '#111827',
|
||||
fontWeight: 800,
|
||||
marginTop: isHighlight ? 6 : 2,
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{formattedValue}
|
||||
{metric.unit && formattedValue !== '—' ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: '#6b7280',
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{metric.unit}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</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,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,120 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { Karung } from '../../types/api.ts';
|
||||
import { formatNumber } from '../../utils/format.ts';
|
||||
|
||||
interface FeedSaldoCardProps {
|
||||
latest: Karung | null;
|
||||
feedInitialBalance: number;
|
||||
cycleStartDate: string;
|
||||
displayDate: string;
|
||||
}
|
||||
|
||||
const FeedSaldoCard: React.FC<FeedSaldoCardProps> = ({
|
||||
latest,
|
||||
feedInitialBalance,
|
||||
cycleStartDate,
|
||||
displayDate,
|
||||
}) => {
|
||||
const saldoData = useMemo(() => {
|
||||
const inbound = (latest?.in_total ?? 0) + feedInitialBalance;
|
||||
const feedUse = latest?.feed_use_total ?? 0;
|
||||
const keluar = latest?.out_total ?? 0;
|
||||
const saldo = inbound - feedUse - keluar;
|
||||
return { inbound, feedUse, keluar, saldo };
|
||||
}, [latest, feedInitialBalance]);
|
||||
|
||||
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="mt-0.5 shrink-0 text-gray-500">{icon}</div>
|
||||
<p className="min-w-0 text-[11px] sm:text-xs font-medium leading-tight text-gray-700 break-words">
|
||||
{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 ? formatNumber(0) : 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 ?? 0,
|
||||
iotTotal: latest?.in_total ?? 0,
|
||||
manualToday: latestManual?.feed_in_manual ?? 0,
|
||||
manualTotal: latestManual?.feed_in_manual_total ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'dituang',
|
||||
title: 'Karung Dituang',
|
||||
color: '#ea580c',
|
||||
icon: <i className="fa-solid fa-arrow-up text-orange-600"></i>,
|
||||
iotToday: latest?.feed_use_today ?? 0,
|
||||
iotTotal: latest?.feed_use_total ?? 0,
|
||||
manualToday: latestManual?.feed_use_manual ?? 0,
|
||||
manualTotal: latestManual?.feed_use_manual_total ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'keluar',
|
||||
title: 'Karung Keluar',
|
||||
color: '#dc2626',
|
||||
icon: <i className="fa-solid fa-arrow-right text-red-600"></i>,
|
||||
iotToday: latest?.out_today ?? 0,
|
||||
iotTotal: latest?.out_total ?? 0,
|
||||
manualToday: latestManual?.feed_out_manual ?? 0,
|
||||
manualTotal: latestManual?.feed_out_manual_total ?? 0,
|
||||
},
|
||||
],
|
||||
[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,108 @@
|
||||
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; keluar: 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,
|
||||
keluar: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const byKey = new Map<string, { masuk: number; dituang: number; keluar: number }>();
|
||||
for (const row of rows) {
|
||||
const bucket = byKey.get(row.date) ?? { masuk: 0, dituang: 0, keluar: 0 };
|
||||
bucket.masuk += row.in_today;
|
||||
bucket.dituang += row.feed_use_today ?? 0;
|
||||
bucket.keluar += row.out_today ?? 0;
|
||||
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;
|
||||
entry.keluar = bucket.keluar;
|
||||
}
|
||||
}
|
||||
|
||||
return dailyData;
|
||||
}, [rows, cycleStartDate, currentDay]);
|
||||
|
||||
const seriesLabel = (name: string) => {
|
||||
if (name === 'masuk') return 'Karung Masuk';
|
||||
if (name === 'dituang') return 'Karung Dituang';
|
||||
return 'Karung Keluar';
|
||||
};
|
||||
|
||||
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`,
|
||||
seriesLabel(String(name)),
|
||||
]}
|
||||
labelFormatter={(label) => `Tanggal: ${label}`}
|
||||
/>
|
||||
<Legend formatter={(value) => seriesLabel(String(value))} />
|
||||
<Bar dataKey="masuk" name="masuk" fill="#2563eb" barSize={8} />
|
||||
<Bar dataKey="dituang" name="dituang" fill="#ea580c" barSize={8} />
|
||||
<Bar dataKey="keluar" name="keluar" fill="#dc2626" barSize={8} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedTrendChart;
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { api, errorMessage } from '../../services/apiClient.ts';
|
||||
import type { Cycle, InitialBalanceResult } from '../../types/api.ts';
|
||||
import { formatNumber } from '../../utils/format.ts';
|
||||
import InitialBalanceComparison from './InitialBalanceComparison.tsx';
|
||||
|
||||
interface InitialBalanceCardProps {
|
||||
cycle: Cycle;
|
||||
}
|
||||
|
||||
const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle }) => {
|
||||
const savedBalanceDate = cycle.feed_initial_balance_date ?? cycle.start_date;
|
||||
const [balanceDate, setBalanceDate] = useState(savedBalanceDate);
|
||||
const [manualEntry, setManualEntry] = useState<string>(
|
||||
cycle.feed_initial_balance > 0 ? String(cycle.feed_initial_balance) : ''
|
||||
);
|
||||
const [comparing, setComparing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [comparison, setComparison] = useState<InitialBalanceResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBalanceDate(cycle.feed_initial_balance_date ?? cycle.start_date);
|
||||
setManualEntry(cycle.feed_initial_balance > 0 ? String(cycle.feed_initial_balance) : '');
|
||||
setComparison(null);
|
||||
setError(null);
|
||||
}, [cycle.id, cycle.feed_initial_balance, cycle.feed_initial_balance_date, cycle.start_date]);
|
||||
|
||||
const manualValue = manualEntry === '' ? null : Number(manualEntry);
|
||||
|
||||
const loadComparison = useCallback(async () => {
|
||||
if (balanceDate === '' || manualValue == null || Number.isNaN(manualValue) || manualValue < 0) {
|
||||
setComparison(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setComparing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const compared = await api.cycles.initialBalanceCompare(cycle.id, {
|
||||
date: balanceDate,
|
||||
manual: manualValue,
|
||||
});
|
||||
setComparison(compared);
|
||||
} catch (err) {
|
||||
setComparison(null);
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setComparing(false);
|
||||
}
|
||||
}, [balanceDate, cycle.id, manualValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (manualValue == null || manualValue < 0) {
|
||||
setComparison(null);
|
||||
return;
|
||||
}
|
||||
void loadComparison();
|
||||
}, [loadComparison, manualValue]);
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{cycle.feed_initial_balance > 0 && comparison == null && (
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Saldo awal tersimpan:{' '}
|
||||
<strong className="text-blue-700">
|
||||
{formatNumber(cycle.feed_initial_balance)} karung
|
||||
</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{comparison && <InitialBalanceComparison result={comparison} loading={comparing} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InitialBalanceCard;
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import type { InitialBalanceResult } from '../../types/api.ts';
|
||||
import { formatNumber } from '../../utils/format.ts';
|
||||
|
||||
interface InitialBalanceComparisonProps {
|
||||
result: InitialBalanceResult;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const InitialBalanceComparison: React.FC<InitialBalanceComparisonProps> = ({
|
||||
result,
|
||||
loading = 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="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}
|
||||
{loading ? ' · memuat IoT...' : ''}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default InitialBalanceComparison;
|
||||
@@ -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,130 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View } from '../../types/common.ts';
|
||||
|
||||
export interface NavItem {
|
||||
view: View;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
permissionId?: string;
|
||||
href?: 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.href ?? subItem.view}>
|
||||
{subItem.href ? (
|
||||
<a
|
||||
href={subItem.href}
|
||||
className={`w-full flex items-center h-9 pl-3 pr-2 rounded-md transition-colors duration-200 group text-left 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>
|
||||
</a>
|
||||
) : (
|
||||
<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 di header. GM mendaftarkan site di{' '}
|
||||
<button type="button" className="font-semibold underline" onClick={onSettings}>
|
||||
Pengaturan
|
||||
</button>
|
||||
; BUH melihat rollup site dari GM yang dikelola.
|
||||
</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;
|
||||
docWeight: 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, docWeight }) => {
|
||||
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, docWeight);
|
||||
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[];
|
||||
docWeight: 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, docWeight }) => {
|
||||
const chartData = useMemo<ChartPoint[]>(
|
||||
() =>
|
||||
[...rows].reverse().map((row) => {
|
||||
const targetGain = getTargetGainForAge(row.age, docWeight);
|
||||
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, docWeight]
|
||||
);
|
||||
|
||||
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[];
|
||||
docWeight: number;
|
||||
}
|
||||
|
||||
type ChartPoint = {
|
||||
day: number;
|
||||
date: string;
|
||||
actual: number | null;
|
||||
target: number | null;
|
||||
chickenCount: number;
|
||||
};
|
||||
|
||||
const DailyWeightProgressionChart: React.FC<DailyWeightProgressionChartProps> = ({
|
||||
rows,
|
||||
docWeight,
|
||||
}) => {
|
||||
const chartData = useMemo<ChartPoint[]>(
|
||||
() =>
|
||||
[...rows].reverse().map((row) => ({
|
||||
day: row.age,
|
||||
date: row.date,
|
||||
actual: row.average_weight,
|
||||
target: getTargetWeightForAge(row.age, docWeight),
|
||||
chickenCount: row.chicken_count,
|
||||
})),
|
||||
[rows, docWeight]
|
||||
);
|
||||
|
||||
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;
|
||||
docWeight: number;
|
||||
}
|
||||
|
||||
const WeightStats: React.FC<WeightStatsProps> = ({ latest, docWeight }) => {
|
||||
const target = latest ? getTargetWeightForAge(latest.age, docWeight) : 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