first commit

This commit is contained in:
Alberto-Audrix committed 2026-09-08 15:21:23 +07:00
commit b54624be96
226 files changed
+108840

No files matched your search

+51
View File
@@ -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;
+181
View File
@@ -0,0 +1,181 @@
import React, { useEffect, useMemo, useState } from 'react';
import { cobbStandardPerformance } from '../mockData/cobbStandard.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { formatNumber, formatRatio } from '../utils/format.ts';
const CobbStandardPage: React.FC = () => {
const { selectedCycle } = useFarm();
const [initialBw, setInitialBw] = useState<number>(selectedCycle?.chick_in_weight || 42);
const [capacity, setCapacity] = useState<number>(selectedCycle?.doc_in_count || 50000);
useEffect(() => {
if (selectedCycle?.chick_in_weight) setInitialBw(selectedCycle.chick_in_weight);
if (selectedCycle?.doc_in_count) setCapacity(selectedCycle.doc_in_count);
}, [selectedCycle?.chick_in_weight, selectedCycle?.doc_in_count]);
const calculatedData = useMemo(() => {
const results = [];
let lastBw = Number(initialBw);
let lastCumFeed = 0;
for (const standard of cobbStandardPerformance) {
const currentBw = lastBw * (1 + standard.dailyGainPercent);
const dailyGainG = currentBw - lastBw;
const cumFeedG = currentBw * standard.fcr;
const dailyFeedG = cumFeedG - lastCumFeed;
const feedKg = (dailyFeedG * capacity) / 1000;
const feedBag = feedKg / 50;
results.push({
day: standard.day,
bw: Math.round(currentBw),
dailyGainG: Math.round(dailyGainG),
dailyGainPercent: standard.dailyGainPercent,
dailyFeedG: Math.round(dailyFeedG),
cumFeedG: Math.round(cumFeedG),
fcr: standard.fcr,
feedKg: Math.round(feedKg),
feedBag: Math.round(feedBag),
});
lastBw = currentBw;
lastCumFeed = cumFeedG;
}
return results;
}, [initialBw, capacity]);
return (
<main className="pb-8">
<div className="flex items-center mb-6">
<i className="fa-solid fa-calculator text-3xl text-red-600 mr-4"></i>
<div>
<h1 className="text-3xl font-bold text-gray-800">Kalkulator Standar Performa</h1>
<p className="mt-1 text-gray-600">
Hitung proyeksi pertumbuhan dan kebutuhan pakan berdasarkan bobot awal dan kapasitas
kandang.
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="bg-white p-4 rounded-xl shadow-sm">
<label htmlFor="initialBw" className="block text-sm font-medium text-gray-700">
Bobot Awal DOC (g)
</label>
<input
type="number"
id="initialBw"
value={initialBw}
onChange={(e) => setInitialBw(Number(e.target.value))}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 text-lg font-semibold"
/>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm">
<label htmlFor="capacity" className="block text-sm font-medium text-gray-700">
Kapasitas Kandang (ekor)
</label>
<input
type="number"
id="capacity"
value={capacity}
onChange={(e) => setCapacity(Number(e.target.value))}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 text-lg font-semibold"
/>
</div>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm overflow-hidden">
<div className="overflow-auto max-h-[calc(100vh-380px)]">
<table className="min-w-full border-collapse text-sm">
<thead className="bg-gray-700 text-white sticky top-0 z-10">
<tr>
<th
rowSpan={2}
className="p-2 border border-gray-800 font-semibold sticky left-0 bg-gray-700 z-20"
>
Umur
</th>
<th rowSpan={2} className="p-2 border border-gray-800 font-semibold">
BW (g)
</th>
<th colSpan={2} className="p-2 border border-gray-800 font-semibold">
Daily Gain
</th>
<th colSpan={2} className="p-2 border border-gray-800 font-semibold">
Feed Consumption
</th>
<th rowSpan={2} className="p-2 border border-gray-800 font-semibold">
FCR
</th>
<th colSpan={2} className="p-2 border border-gray-800 font-semibold">
Kebutuhan Pakan
</th>
</tr>
<tr>
<th className="p-2 border border-gray-800 font-semibold">(g)</th>
<th className="p-2 border border-gray-800 font-semibold">(%)</th>
<th className="p-2 border border-gray-800 font-semibold">Daily (g)</th>
<th className="p-2 border border-gray-800 font-semibold">Cum (g)</th>
<th className="p-2 border border-gray-800 font-semibold">(Kg)</th>
<th className="p-2 border border-gray-800 font-semibold">(Bag)</th>
</tr>
</thead>
<tbody className="font-mono">
<tr className="bg-yellow-100 font-bold">
<td className="p-1 border border-gray-200 text-center bg-gray-700 text-white sticky left-0 z-10">
0
</td>
<td className="p-1 border border-gray-200 text-center text-gray-700">
{initialBw}
</td>
<td className="p-1 border border-gray-200 text-center" colSpan={7}></td>
</tr>
{calculatedData.map((row) => {
const isSeventhDay = row.day > 0 && row.day % 7 === 0;
const rowClass = `hover:bg-red-50 ${isSeventhDay ? 'font-bold bg-blue-100' : 'even:bg-gray-100/50'}`;
const cellBorderClass = isSeventhDay
? 'border-b-[3px] border-double border-blue-400'
: 'border-gray-200';
return (
<tr key={row.day} className={rowClass}>
<td
className={`p-1 border ${cellBorderClass} text-center bg-gray-700 text-white font-semibold sticky left-0 z-10`}
>
{row.day}
</td>
<td
className={`p-1 border ${cellBorderClass} text-center font-bold text-red-600`}
>
{formatNumber(row.bw)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatNumber(row.dailyGainG)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{(row.dailyGainPercent * 100).toFixed(0)}%
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatNumber(row.dailyFeedG)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatNumber(row.cumFeedG)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatRatio(row.fcr)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatNumber(row.feedKg)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatNumber(row.feedBag)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<div className="text-right mt-2 text-xs text-gray-500 font-semibold">
Standar Performa COBB (2018)
</div>
</main>
);
};
export default CobbStandardPage;
+172
View File
@@ -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;
+393
View File
@@ -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;
+471
View File
@@ -0,0 +1,471 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { api, errorMessage } from '../services/apiClient.ts';
import type { AIInsight, KPI } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
import { formatNumber, formatRatio } from '../utils/format.ts';
type EefRow = {
day: number;
chickenLifePercentage: number | null;
averageHarvestBw: number | null;
averageHarvestDay: number | null;
fcr: number | null;
eef: number | null;
};
type TrendPoint = { day: number; eef: number | null };
type SupportPoint = {
day: number;
chickenLifePercentage: number | null;
averageHarvestBw: number | null;
averageHarvestDay: number | null;
fcr: number | null;
};
const formatThreeDecimal = (value: number | null): string =>
value == null ? 'N/A' : formatRatio(value, 3);
const formatNum = (value: number | null | undefined, maxDecimals = 0): string =>
value == null || !Number.isFinite(Number(value))
? 'N/A'
: formatNumber(Number(value), maxDecimals, 0, true);
const StatCard: React.FC<{ title: string; value: string; unit: string }> = ({
title,
value,
unit,
}) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<p className="text-sm font-semibold text-gray-500">{title}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-bold text-gray-800">{value}</span>
<span className="text-sm font-semibold text-gray-500">{unit}</span>
</div>
</div>
);
const EefTrendChart: React.FC<{ data: TrendPoint[] }> = ({ data }) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Grafik EEF</h2>
<div className="h-80">
{data.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Tidak ada data EEF tersedia.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
tickFormatter={(value) => formatNum(Number(value), 0)}
width={60}
domain={[0, 'dataMax + 20']}
label={{ value: 'EEF', angle: -90, position: 'insideLeft' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #d1d5db',
borderRadius: '8px',
}}
formatter={(value) => [formatThreeDecimal(Number(value)), 'EEF']}
labelFormatter={(label) => `Hari ${label}`}
/>
<Line
type="monotone"
dataKey="eef"
name="EEF"
stroke="#dc2626"
strokeWidth={2.5}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
);
const SupportingMetricChart: React.FC<{
title: string;
data: SupportPoint[];
dataKey: keyof Omit<SupportPoint, 'day'>;
unit: string;
stroke: string;
maximumFractionDigits?: number;
valueFormatter?: (value: number) => string;
}> = ({ title, data, dataKey, unit, stroke, maximumFractionDigits = 0, valueFormatter }) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="text-lg font-bold text-gray-800 mb-4">{title}</h3>
<div className="h-64">
{data.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Tidak ada data tersedia.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
tickFormatter={(value) =>
valueFormatter
? valueFormatter(Number(value))
: formatNum(Number(value), maximumFractionDigits)
}
width={70}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #d1d5db',
borderRadius: '8px',
}}
formatter={(value) => {
const formatted = valueFormatter
? valueFormatter(Number(value))
: formatNum(Number(value), maximumFractionDigits);
return [`${formatted}${unit ? ` ${unit}` : ''}`, title];
}}
labelFormatter={(label) => `Hari ${label}`}
/>
<Line
type="monotone"
dataKey={dataKey}
name={title}
stroke={stroke}
strokeWidth={2.5}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
);
const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle } = useFarm();
const [rows, setRows] = useState<KPI[]>([]);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const loadData = useCallback(async () => {
if (!selectedCycle) return;
setError(null);
try {
const [kpiRows, insightRows] = await Promise.all([
api.kpis.list({ cycle_id: selectedCycle.id }),
api.insights.list({ cycle_id: selectedCycle.id }),
]);
setRows(kpiRows);
setInsights(insightRows);
setLastUpdated(new Date());
} catch (err) {
setError(errorMessage(err));
}
}, [selectedCycle]);
useEffect(() => {
if (!selectedCycle) {
setRows([]);
setInsights([]);
setLastUpdated(null);
return;
}
let cancelled = false;
(async () => {
setLoading(true);
try {
await loadData();
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedCycle, loadData]);
const handleRefresh = async () => {
setRefreshing(true);
try {
await loadData();
} finally {
setRefreshing(false);
}
};
const eefRows = useMemo<EefRow[]>(
() =>
[...rows]
.sort((a, b) => a.age - b.age || a.date.localeCompare(b.date))
.map((row) => ({
day: row.age,
chickenLifePercentage: row.chicken_life_percentage,
averageHarvestBw: row.average_harvest_weight > 0 ? row.average_harvest_weight : null,
averageHarvestDay: row.average_harvest_day > 0 ? row.average_harvest_day : null,
fcr: row.fcr > 0 ? row.fcr : null,
eef: row.eef > 0 ? row.eef : null,
})),
[rows]
);
const trendData = useMemo<TrendPoint[]>(
() => eefRows.map((row) => ({ day: row.day, eef: row.eef })),
[eefRows]
);
const supportData = useMemo<SupportPoint[]>(
() =>
eefRows.map((row) => ({
day: row.day,
chickenLifePercentage: row.chickenLifePercentage,
averageHarvestBw: row.averageHarvestBw,
averageHarvestDay: row.averageHarvestDay,
fcr: row.fcr,
})),
[eefRows]
);
const getLatestValid = <K extends keyof EefRow>(key: K): EefRow[K] | null => {
for (let i = eefRows.length - 1; i >= 0; i--) {
const val = eefRows[i]?.[key];
if (val !== null && val !== undefined && val !== 0 && String(val) !== '') {
return val;
}
}
return null;
};
const latestEef = getLatestValid('eef');
const eefInsights = useMemo(() => {
const matched = insights.filter((item) => {
const text =
`${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase();
return (
text.includes('eef') ||
text.includes('fcr') ||
text.includes('hidup') ||
text.includes('live') ||
text.includes('panen') ||
text.includes('bw')
);
});
return (matched.length > 0 ? matched : insights).slice(0, 4);
}, [insights]);
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
return (
<main className="mx-auto max-w-[1240px] space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<PageHeader
title="EEF"
subtitle="Dashboard European Efficiency Factor berdasarkan data performa produksi IoT."
icon={<i className="fa-solid fa-gauge-high" />}
/>
<button
onClick={handleRefresh}
disabled={refreshing || loading}
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
<i className={`fa-solid fa-rotate-right ${refreshing || loading ? 'fa-spin' : ''}`} />
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
</button>
</div>
{error && (
<div>
<Banner kind="error">{error}</Banner>
</div>
)}
<section>
<h2 className="text-lg font-bold text-gray-800 mb-3">
<i className="fa-solid fa-brain text-red-600 mr-2" />
AI Insight EEF
</h2>
{loading ? (
<Spinner />
) : eefInsights.length === 0 ? (
<Banner>Belum ada insight untuk siklus ini.</Banner>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{eefInsights.map((item) => (
<article
key={item.id}
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">{item.section}</p>
<h3 className="font-semibold text-gray-800 mt-1">{item.alert || 'Insight'}</h3>
</div>
<span className="text-xs text-gray-400">{item.session}</span>
</div>
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
{item.insight_text}
</p>
</article>
))}
</div>
)}
</section>
{loading ? (
<Spinner />
) : (
<div className="space-y-8">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-6">
<StatCard title="EEF" value={formatThreeDecimal(latestEef)} unit="" />
<StatCard
title="Persentase Ayam Hidup"
value={formatNum(getLatestValid('chickenLifePercentage'), 3)}
unit="%"
/>
<StatCard
title="BW Rata-rata Panen"
value={formatNum(getLatestValid('averageHarvestBw'), 2)}
unit="g"
/>
<StatCard
title="Umur Rata-rata Panen"
value={formatNum(getLatestValid('averageHarvestDay'), 2)}
unit="hari"
/>
<StatCard title="FCR" value={formatThreeDecimal(getLatestValid('fcr'))} unit="" />
</div>
<EefTrendChart data={trendData} />
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-6 py-4 border-b border-gray-100">
<h2 className="text-xl font-bold text-gray-800">Data Pendukung EEF</h2>
{lastUpdated && (
<p className="text-sm text-gray-500">
Update:{' '}
{lastUpdated.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}
</p>
)}
</div>
<div className="p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Grafik Data Pendukung</h2>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<SupportingMetricChart
title="Persentase Ayam Hidup"
data={supportData}
dataKey="chickenLifePercentage"
unit="%"
stroke="#2563eb"
maximumFractionDigits={3}
/>
<SupportingMetricChart
title="BW Rata-rata Panen"
data={supportData}
dataKey="averageHarvestBw"
unit="g"
stroke="#16a34a"
maximumFractionDigits={2}
/>
<SupportingMetricChart
title="Umur Rata-rata Panen"
data={supportData}
dataKey="averageHarvestDay"
unit=""
stroke="#9333ea"
maximumFractionDigits={2}
/>
<SupportingMetricChart
title="FCR"
data={supportData}
dataKey="fcr"
unit=""
stroke="#dc2626"
valueFormatter={(value) => formatThreeDecimal(value)}
/>
</div>
</div>
<div className="border-t border-gray-100">
<div className="px-6 py-4">
<h2 className="text-xl font-bold text-gray-800">Tabel Data Pendukung EEF</h2>
</div>
<div className="overflow-auto max-h-[420px] border-t border-gray-100">
<table className="min-w-full text-sm">
<thead className="bg-gray-700 text-white sticky top-0 z-10">
<tr>
<th className="p-3 text-left font-semibold">Umur</th>
<th className="p-3 text-right font-semibold">Persentase Ayam Hidup</th>
<th className="p-3 text-right font-semibold">BW Rata-rata Panen</th>
<th className="p-3 text-right font-semibold">Umur Rata-rata Panen</th>
<th className="p-3 text-right font-semibold">FCR</th>
<th className="p-3 text-right font-semibold">EEF</th>
</tr>
</thead>
<tbody>
{eefRows.map((row) => (
<tr key={row.day} className="even:bg-gray-50 hover:bg-red-50">
<td className="p-3 font-semibold text-gray-800">Hari {row.day}</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.chickenLifePercentage, 3)}
{row.chickenLifePercentage != null ? '%' : ''}
</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.averageHarvestBw, 2)}
{row.averageHarvestBw != null ? ' g' : ''}
</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.averageHarvestDay, 2)}
</td>
<td className="p-3 text-right font-mono text-gray-700">
{formatThreeDecimal(row.fcr)}
</td>
<td className="p-3 text-right font-mono text-gray-700">
{formatThreeDecimal(row.eef)}
</td>
</tr>
))}
{eefRows.length === 0 && (
<tr>
<td colSpan={6} className="p-8 text-center text-gray-500">
Tidak ada data EEF tersedia.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</main>
);
};
export default EefDashboardPage;
+473
View File
@@ -0,0 +1,473 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { api, errorMessage } from '../services/apiClient.ts';
import type { AIInsight, ChickenWeight, KPI } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
import { formatNumber, formatRatio } from '../utils/format.ts';
type FcrRow = {
day: number;
feedTotal: number;
iotWeight: number | null;
chickenLife: number;
harvestWeightKg: number;
fcr: number | null;
};
type TrendPoint = { day: number; fcr: number | null };
type SupportPoint = {
day: number;
feedTotal: number;
iotWeight: number | null;
chickenLife: number;
harvestWeightKg: number;
};
const formatFcr = (value: number | null): string => (value == null ? 'N/A' : formatRatio(value, 3));
const formatNum = (value: number | null | undefined, maxDecimals = 0): string =>
value == null || !Number.isFinite(Number(value))
? 'N/A'
: formatNumber(Number(value), maxDecimals, 0, true);
const StatCard: React.FC<{ title: string; value: string; unit: string }> = ({
title,
value,
unit,
}) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<p className="text-sm font-semibold text-gray-500">{title}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-bold text-gray-800">{value}</span>
<span className="text-sm font-semibold text-gray-500">{unit}</span>
</div>
</div>
);
const FcrTrendChart: React.FC<{ data: TrendPoint[] }> = ({ data }) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Grafik FCR</h2>
<div className="h-80">
{data.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Tidak ada data FCR tersedia.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: -10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
width={50}
domain={[0, 'dataMax + 0.2']}
label={{ value: 'FCR', angle: -90, position: 'insideLeft' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #d1d5db',
borderRadius: '8px',
}}
formatter={(value) => [formatFcr(Number(value)), 'FCR']}
labelFormatter={(label) => `Hari ${label}`}
/>
<Line
type="monotone"
dataKey="fcr"
name="FCR"
stroke="#16a34a"
strokeWidth={2.5}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
);
const SupportingMetricChart: React.FC<{
title: string;
data: SupportPoint[];
dataKey: keyof Omit<SupportPoint, 'day'>;
unit: string;
stroke: string;
maximumFractionDigits?: number;
}> = ({ title, data, dataKey, unit, stroke, maximumFractionDigits = 0 }) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="text-lg font-bold text-gray-800 mb-4">{title}</h3>
<div className="h-64">
{data.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Tidak ada data tersedia.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
tickFormatter={(value) => formatNum(Number(value), maximumFractionDigits)}
width={70}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #d1d5db',
borderRadius: '8px',
}}
formatter={(value) => [
`${formatNum(Number(value), maximumFractionDigits)} ${unit}`,
title,
]}
labelFormatter={(label) => `Hari ${label}`}
/>
<Line
type="monotone"
dataKey={dataKey}
name={title}
stroke={stroke}
strokeWidth={2.5}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
);
const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle } = useFarm();
const [rows, setRows] = useState<KPI[]>([]);
const [weightRows, setWeightRows] = useState<ChickenWeight[]>([]);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const loadData = useCallback(async () => {
if (!selectedCycle) return;
setError(null);
try {
const [kpiRows, insightRows, weightList] = await Promise.all([
api.kpis.list({ cycle_id: selectedCycle.id }),
api.insights.list({ cycle_id: selectedCycle.id }),
api.weights.list({ cycle_id: selectedCycle.id }),
]);
setRows(kpiRows);
setInsights(insightRows);
setWeightRows(weightList);
setLastUpdated(new Date());
} catch (err) {
setError(errorMessage(err));
}
}, [selectedCycle]);
useEffect(() => {
if (!selectedCycle) {
setRows([]);
setWeightRows([]);
setInsights([]);
setLastUpdated(null);
return;
}
let cancelled = false;
(async () => {
setLoading(true);
try {
await loadData();
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedCycle, loadData]);
const handleRefresh = async () => {
setRefreshing(true);
try {
await loadData();
} finally {
setRefreshing(false);
}
};
const weightByAge = useMemo(() => {
const map = new Map<number, number>();
for (const weight of weightRows) {
if (weight.average_weight > 0 && !map.has(weight.age)) {
map.set(weight.age, weight.average_weight);
}
}
return map;
}, [weightRows]);
const fcrRows = useMemo<FcrRow[]>(
() =>
[...rows]
.sort((a, b) => a.age - b.age || a.date.localeCompare(b.date))
.map((row) => ({
day: row.age,
feedTotal: row.feed_total,
iotWeight: weightByAge.get(row.age) ?? null,
chickenLife: row.chicken_life,
harvestWeightKg: row.harvest_weight_total / 1000,
fcr: row.fcr > 0 ? row.fcr : null,
})),
[rows, weightByAge]
);
const trendData = useMemo<TrendPoint[]>(
() => fcrRows.map((row) => ({ day: row.day, fcr: row.fcr })),
[fcrRows]
);
const supportData = useMemo<SupportPoint[]>(
() =>
fcrRows.map((row) => ({
day: row.day,
feedTotal: row.feedTotal,
iotWeight: row.iotWeight,
chickenLife: row.chickenLife,
harvestWeightKg: row.harvestWeightKg,
})),
[fcrRows]
);
const getLatestValid = <K extends keyof FcrRow>(key: K): FcrRow[K] | null => {
for (let i = fcrRows.length - 1; i >= 0; i--) {
const val = fcrRows[i]?.[key];
if (val !== null && val !== undefined && val !== 0 && String(val) !== '') {
return val;
}
}
return null;
};
const latestFcr = getLatestValid('fcr');
const latestIotWeight = getLatestValid('iotWeight');
const fcrInsights = useMemo(() => {
const matched = insights.filter((item) => {
const text =
`${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase();
return (
text.includes('fcr') ||
text.includes('pakan') ||
text.includes('feed') ||
text.includes('konversi') ||
text.includes('panen')
);
});
return (matched.length > 0 ? matched : insights).slice(0, 4);
}, [insights]);
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
return (
<main className="mx-auto max-w-[1240px] space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<PageHeader
title="FCR"
subtitle="Dashboard Feed Conversion Ratio berdasarkan data pakan, bobot IoT, ayam hidup, dan panen."
icon={<i className="fa-solid fa-chart-line" />}
/>
<button
onClick={handleRefresh}
disabled={refreshing || loading}
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
<i className={`fa-solid fa-rotate-right ${refreshing || loading ? 'fa-spin' : ''}`} />
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
</button>
</div>
{error && (
<div>
<Banner kind="error">{error}</Banner>
</div>
)}
<section>
<h2 className="text-lg font-bold text-gray-800 mb-3">
<i className="fa-solid fa-brain text-red-600 mr-2" />
AI Insight FCR
</h2>
{loading ? (
<Spinner />
) : fcrInsights.length === 0 ? (
<Banner>Belum ada insight untuk siklus ini.</Banner>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{fcrInsights.map((item) => (
<article
key={item.id}
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">{item.section}</p>
<h3 className="font-semibold text-gray-800 mt-1">{item.alert || 'Insight'}</h3>
</div>
<span className="text-xs text-gray-400">{item.session}</span>
</div>
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
{item.insight_text}
</p>
</article>
))}
</div>
)}
</section>
{loading ? (
<Spinner />
) : (
<div className="space-y-8">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-6">
<StatCard title="FCR" value={formatFcr(latestFcr)} unit="" />
<StatCard
title="Total Pakan"
value={formatNum(getLatestValid('feedTotal') ?? 0)}
unit="Karung"
/>
<StatCard title="Bobot IoT" value={formatNum(latestIotWeight, 2)} unit="g" />
<StatCard
title="Ayam Hidup"
value={formatNum(getLatestValid('chickenLife') ?? 0)}
unit="Ekor"
/>
<StatCard
title="Total Bobot Panen"
value={formatNum(getLatestValid('harvestWeightKg') ?? 0, 1)}
unit="kg"
/>
</div>
<FcrTrendChart data={trendData} />
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-6 py-4 border-b border-gray-100">
<h2 className="text-xl font-bold text-gray-800">Data Pendukung FCR</h2>
{lastUpdated && (
<p className="text-sm text-gray-500">
Update:{' '}
{lastUpdated.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}
</p>
)}
</div>
<div className="p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Grafik Data Pendukung</h2>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<SupportingMetricChart
title="Total Pakan"
data={supportData}
dataKey="feedTotal"
unit="karung"
stroke="#dc2626"
/>
<SupportingMetricChart
title="Bobot IoT"
data={supportData}
dataKey="iotWeight"
unit="g"
stroke="#16a34a"
maximumFractionDigits={2}
/>
<SupportingMetricChart
title="Ayam Hidup"
data={supportData}
dataKey="chickenLife"
unit="ekor"
stroke="#2563eb"
/>
<SupportingMetricChart
title="Total Bobot Panen"
data={supportData}
dataKey="harvestWeightKg"
unit="kg"
stroke="#9333ea"
maximumFractionDigits={1}
/>
</div>
</div>
<div className="border-t border-gray-100">
<div className="px-6 py-4">
<h2 className="text-xl font-bold text-gray-800">Tabel Data Pendukung FCR</h2>
</div>
<div className="overflow-auto max-h-[420px] border-t border-gray-100">
<table className="min-w-full text-sm">
<thead className="bg-gray-700 text-white sticky top-0 z-10">
<tr>
<th className="p-3 text-left font-semibold">Umur</th>
<th className="p-3 text-right font-semibold">Total Pakan</th>
<th className="p-3 text-right font-semibold">Bobot IoT</th>
<th className="p-3 text-right font-semibold">Ayam Hidup</th>
<th className="p-3 text-right font-semibold">Total Bobot Panen</th>
<th className="p-3 text-right font-semibold">FCR</th>
</tr>
</thead>
<tbody>
{fcrRows.map((row) => (
<tr key={row.day} className="even:bg-gray-50 hover:bg-red-50">
<td className="p-3 font-semibold text-gray-800">Hari {row.day}</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.feedTotal)} karung
</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.iotWeight, 2)}
{row.iotWeight != null ? ' g' : ''}
</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.chickenLife)} ekor
</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.harvestWeightKg, 1)} kg
</td>
<td className="p-3 text-right font-mono text-gray-700">
{formatFcr(row.fcr)}
</td>
</tr>
))}
{fcrRows.length === 0 && (
<tr>
<td colSpan={6} className="p-8 text-center text-gray-500">
Tidak ada data FCR tersedia.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</main>
);
};
export default FcrDashboardPage;
+321
View File
@@ -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;
+217
View File
@@ -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;
+595
View File
@@ -0,0 +1,595 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { api, errorMessage } from '../services/apiClient.ts';
import type { AIInsight, Flock, PanelIoT } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
import { cycleDayForDate, formatDateId, formatDateRange, formatNumber } from '../utils/format.ts';
import { computeExperienceCalc } from './iotPanel/chillFactor.ts';
import IotPanelChillFactorCard from './iotPanel/IotPanelChillFactorCard.tsx';
import IotPanelExperienceTempCard from './iotPanel/IotPanelExperienceTempCard.tsx';
const cardClass = 'rounded-xl border border-slate-200 bg-white p-4 shadow-sm';
const SECONDARY_CHARTS = [
{ key: 'humidity', label: 'Kelembapan', unit: ' %', color: '#3b82f6' },
{ key: 'wind', label: 'Angin', unit: ' m/s', color: '#6366f1' },
{ key: 'water', label: 'Air', unit: ' L', color: '#06b6d4' },
] as const;
const display = (value: number | null | undefined, suffix = '') =>
value == null || !Number.isFinite(Number(value))
? 'N/A'
: `${formatNumber(Number(value))}${suffix}`;
const formatTime = (iso: string | null | undefined): string => {
if (!iso) return '--';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
};
type DaySummary = {
count: number;
temp: number;
minTemp: number;
maxTemp: number;
experienceTemp: number;
minExp: number;
maxExp: number;
humidity: number;
windSpeed: number;
waterTotal: number;
};
const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
const [flocks, setFlocks] = useState<Flock[]>([]);
const [selectedFlockId, setSelectedFlockId] = useState<number | null>(null);
const [dates, setDates] = useState<string[]>([]);
const [selectedDate, setSelectedDate] = useState('');
const [dayRows, setDayRows] = useState<PanelIoT[]>([]);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshToken, setRefreshToken] = useState(0);
const [selectedChartKey, setSelectedChartKey] = useState<'humidity' | 'wind' | 'water' | null>(
null
);
useEffect(() => {
if (!selectedChartKey) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setSelectedChartKey(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selectedChartKey]);
useEffect(() => {
if (!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;
+208
View File
@@ -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;
+129
View File
@@ -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;
+106
View File
@@ -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;
+557
View File
@@ -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;
+151
View File
@@ -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;
+362
View File
@@ -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;
+313
View File
@@ -0,0 +1,313 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
import type { AIInsight, ChickenWeight, LatestAvg } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { Banner, EmptyFarm, Spinner } from './ui/Feedback.tsx';
import { formatDateId, formatNumber } from '../utils/format.ts';
import { formatWeight } from '../utils/weight.ts';
import WeightStats from './weight/WeightStats.tsx';
import DailyWeightProgressionChart from './weight/DailyWeightProgressionChart.tsx';
import DailyWeightGainChart from './weight/DailyWeightGainChart.tsx';
import DailyDetailPanel from './weight/DailyDetailPanel.tsx';
const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle } = useFarm();
const [rows, setRows] = useState<ChickenWeight[]>([]);
const [latest, setLatest] = useState<LatestAvg | null>(null);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState('');
const reload = async (cycleId: number) => {
setError(null);
try {
const [list, insightRows] = await Promise.all([
api.weights.list({ cycle_id: cycleId }),
api.insights.list({ cycle_id: cycleId }),
]);
setRows(list);
setInsights(insightRows);
try {
setLatest(await api.weights.latestAvg({ cycle_id: cycleId }));
} catch (err) {
if (err instanceof ApiError && err.status === 404) setLatest(null);
else throw err;
}
} catch (err) {
setError(errorMessage(err));
}
};
useEffect(() => {
if (!selectedCycle) {
setRows([]);
setLatest(null);
setInsights([]);
setSelectedDate('');
return;
}
let cancelled = false;
(async () => {
setLoading(true);
setRefreshing(false);
try {
await reload(selectedCycle.id);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCycle]);
// Days without a weighing are stored as 0 g. Charts skip those days so the
// line does not drop to zero at the end of a cycle, mirroring the old
// dashboard which only plotted days with actual timbangan data.
const chartRows = useMemo(() => rows.filter((row) => row.average_weight > 0), [rows]);
// Default the date picker to the newest row with a real weighing once data
// loads, so the detail panel opens with meaningful values.
useEffect(() => {
if (!selectedDate) {
const newestMeaningful = chartRows[0] ?? rows[0];
if (newestMeaningful) setSelectedDate(newestMeaningful.date);
}
}, [rows, chartRows, selectedDate]);
const handleRefresh = async () => {
if (!selectedCycle) return;
setRefreshing(true);
try {
await reload(selectedCycle.id);
} catch {
// error state is set inside reload
} finally {
setRefreshing(false);
}
};
const 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;
+62
View File
@@ -0,0 +1,62 @@
import React from 'react';
import { weightStandardData } from '../mockData/weightStandardData.ts';
const WeightStandardPage: React.FC = () => {
const { headers, data } = weightStandardData;
return (
<main className="px-4 sm:px-6 lg:px-8 pb-8">
<div className="flex items-center mb-6">
<i className="fa-solid fa-table-list text-3xl text-red-600 mr-4"></i>
<div>
<h1 className="text-3xl font-bold text-gray-800">Tabel Standar Bobot Ross 308</h1>
<p className="mt-1 text-gray-600">
Driven from pen trial data - so difference may be more
</p>
</div>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm overflow-hidden">
<div className="overflow-auto max-h-[calc(100vh-250px)]">
<table className="min-w-full border-collapse text-sm">
<thead className="bg-red-700 text-white sticky top-0 z-10">
<tr>
<th className="p-2 border border-red-800 font-semibold sticky left-0 bg-red-700 z-20">
DO/wt
</th>
{headers.map((header) => (
<th key={header} className="p-2 border border-red-800 font-semibold w-16">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr key={rowIndex} className="even:bg-red-100/50">
<td className="p-1 border border-red-200 text-center bg-red-700 text-white font-semibold sticky left-0 z-10">
{row.day}
</td>
{row.values.map((value, colIndex) => {
const isRedText = (colIndex + 1) % 7 === 0;
return (
<td
key={colIndex}
className={`p-1 border border-red-200 text-center font-mono ${isRedText ? 'text-red-600 font-bold' : 'text-gray-800'}`}
>
{value}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="text-right mt-2 text-xs text-gray-500 font-semibold">AVIAGEN PROPRIETARY</div>
</main>
);
};
export default WeightStandardPage;
@@ -0,0 +1,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();
}
});
});
+216
View File
@@ -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');
});
});
});
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
import LoginForm from './LoginForm.tsx';
const AuthPage: React.FC = () => (
<div className="min-h-screen bg-gray-100 flex flex-col justify-center items-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<img
src="https://www.cpp.co.id/wp-content/uploads/2021/08/logo-PT-Charoen-Pokphand-Indonesia-Tbk.png"
alt="Charoen Pokphand Logo"
className="h-16 mx-auto mb-4"
/>
<h1 className="text-2xl font-bold text-gray-800">Solusi AI Peternakan Ayam</h1>
</div>
<div className="bg-white p-8 rounded-2xl shadow-xl">
<LoginForm />
</div>
</div>
</div>
);
export default AuthPage;
+78
View File
@@ -0,0 +1,78 @@
import React, { useState } from 'react';
import { useAuth } from '../../context/AuthContext.tsx';
import { errorMessage } from '../../services/apiClient.ts';
const LoginForm: React.FC = () => {
const { login } = useAuth();
const [userName, setUserName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login(userName, password);
} catch (err) {
setError(errorMessage(err));
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-6">
{error && (
<div className="rounded-lg bg-red-50 text-red-700 text-sm px-3 py-2 border border-red-100">
{error}
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<div className="mt-1">
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
value={userName}
onChange={(e) => setUserName(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-red-500 focus:border-red-500 sm:text-sm"
placeholder="Nama pengguna"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Kata Sandi
</label>
<div className="mt-1">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-red-500 focus:border-red-500 sm:text-sm"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-semibold text-white bg-red-600 hover:bg-red-700 disabled:opacity-60"
>
{isLoading ? 'Masuk...' : 'Masuk'}
</button>
</form>
);
};
export default LoginForm;
+55
View File
@@ -0,0 +1,55 @@
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught error:', error, errorInfo);
// TODO: Send to error tracking service (Sentry, LogRocket, etc.)
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md text-center">
<i className="fa-solid fa-triangle-exclamation text-red-500 text-5xl mb-4"></i>
<h2 className="text-2xl font-bold text-gray-800 mb-2">Something went wrong</h2>
<p className="text-gray-600 mb-4">
We&apos;re sorry, but something unexpected happened. Please refresh the page.
</p>
<button
onClick={() => window.location.reload()}
className="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Refresh Page
</button>
</div>
</div>
)
);
}
return this.props.children;
}
}
export default ErrorBoundary;
@@ -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;
+850
View File
@@ -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;
+116
View File
@@ -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;
+120
View File
@@ -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;
+145
View File
@@ -0,0 +1,145 @@
import React, { useMemo } from 'react';
import type { Karung, ManualInput } from '../../types/api.ts';
import { formatNumber } from '../../utils/format.ts';
const StatItem: React.FC<{
icon: React.ReactNode;
label: string;
value: number | null;
colorOverride?: string;
}> = ({ icon, label, value, colorOverride }) => (
<div className="border border-gray-200 rounded-lg p-3 hover:border-gray-300 transition-colors">
<div className="flex items-start gap-2 mb-3 min-w-0">
<div className="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;
+108
View File
@@ -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;
+132
View File
@@ -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;
+79
View File
@@ -0,0 +1,79 @@
export const CHICKEN_CHILL_FACTOR_POINTS = [
{ age: 0, factor: 8 },
{ age: 1, factor: 8 },
{ age: 7, factor: 7 },
{ age: 14, factor: 6 },
{ age: 21, factor: 4.5 },
{ age: 28, factor: 3.5 },
{ age: 35, factor: 3.5 },
{ age: 42, factor: 3 },
] as const;
export function chickenChillFactorFromAge(day: number | null | undefined): number | null {
const age = Number(day);
if (!Number.isFinite(age) || age < 0) return null;
const firstPoint = CHICKEN_CHILL_FACTOR_POINTS[0]!;
if (age <= firstPoint.age) return firstPoint.factor;
for (let idx = 1; idx < CHICKEN_CHILL_FACTOR_POINTS.length; idx += 1) {
const previousPoint = CHICKEN_CHILL_FACTOR_POINTS[idx - 1]!;
const nextPoint = CHICKEN_CHILL_FACTOR_POINTS[idx]!;
if (age > nextPoint.age) continue;
const ageRange = nextPoint.age - previousPoint.age;
if (ageRange <= 0) return nextPoint.factor;
const progress = (age - previousPoint.age) / ageRange;
return previousPoint.factor + (nextPoint.factor - previousPoint.factor) * progress;
}
return CHICKEN_CHILL_FACTOR_POINTS[CHICKEN_CHILL_FACTOR_POINTS.length - 1]!.factor;
}
export type ExperienceCalc = {
averageTemp: number | null;
windSpeed: number | null;
humidity: number | null;
humidityAdjustment: number | null;
chillFactor: number | null;
chillEffect: number | null;
experienceTemp: number | null;
};
export function computeExperienceCalc(args: {
averageTemp: number | null | undefined;
windSpeed: number | null | undefined;
humidity: number | null | undefined;
dayAge: number | null | undefined;
}): ExperienceCalc {
const toNumber = (value: number | null | undefined): number | null =>
value != null && Number.isFinite(Number(value)) ? Number(value) : null;
const averageTemp = toNumber(args.averageTemp);
const windSpeed = toNumber(args.windSpeed);
const humidity = toNumber(args.humidity);
const chillFactor = chickenChillFactorFromAge(args.dayAge);
const humidityAdjustment = humidity === null ? null : (humidity - 70) / 5;
const chillEffect =
windSpeed !== null && chillFactor !== null && humidityAdjustment !== null
? windSpeed * chillFactor - humidityAdjustment
: null;
const experienceTemp =
averageTemp !== null && chillEffect !== null ? averageTemp - chillEffect : null;
return {
averageTemp,
windSpeed,
humidity,
humidityAdjustment,
chillFactor,
chillEffect,
experienceTemp,
};
}
export function formatChillFactor(value: number | null | undefined): string {
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '--';
return Number(value).toFixed(3).replace('.', ',').replace(/0+$/, '').replace(/,$/, '');
}
+130
View File
@@ -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;
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
type BannerProps = {
kind?: 'error' | 'info' | 'success';
children: React.ReactNode;
};
const styles = {
error: 'bg-red-50 text-red-800 border-red-200',
info: 'bg-sky-50 text-sky-800 border-sky-200',
success: 'bg-green-50 text-green-800 border-green-200',
};
export const Banner: React.FC<BannerProps> = ({ kind = 'info', children }) => (
<div className={`rounded-lg border px-4 py-3 text-sm ${styles[kind]}`}>{children}</div>
);
export const PageHeader: React.FC<{
title: string;
subtitle?: string;
icon?: React.ReactNode;
actions?: React.ReactNode;
}> = ({ title, subtitle, icon, actions }) => (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
<div className="flex items-center">
{icon && (
<div className="bg-red-600 text-white p-3 rounded-lg mr-4">
<span className="text-2xl sm:text-3xl">{icon}</span>
</div>
)}
<div>
<h1 className="text-2xl font-bold text-gray-800">{title}</h1>
{subtitle && <p className="text-sm text-gray-500 mt-1">{subtitle}</p>}
</div>
</div>
{actions}
</div>
);
export const Spinner: React.FC = () => (
<div className="flex items-center justify-center py-12 text-gray-400 gap-2">
<i className="fa-solid fa-spinner fa-spin" />
<span>Memuat...</span>
</div>
);
export const EmptyFarm: React.FC<{ onSettings: () => void }> = ({ onSettings }) => (
<Banner>
Pilih site, kandang, dan siklus 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>
);
+103
View File
@@ -0,0 +1,103 @@
import React from 'react';
import type { ChickenWeight } from '../../types/api.ts';
import { formatDateId, formatNumber } from '../../utils/format.ts';
import { formatWeight, getTargetWeightForAge } from '../../utils/weight.ts';
interface DailyDetailPanelProps {
row: ChickenWeight | null;
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;
+193
View File
@@ -0,0 +1,193 @@
import React, { useMemo } from 'react';
import {
Bar,
CartesianGrid,
ComposedChart,
Legend,
Line,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { ChickenWeight } from '../../types/api.ts';
import { formatDateId } from '../../utils/format.ts';
import { formatWeight, getTargetGainForAge } from '../../utils/weight.ts';
interface DailyWeightGainChartProps {
rows: ChickenWeight[];
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;
+90
View File
@@ -0,0 +1,90 @@
import React from 'react';
import type { LatestAvg } from '../../types/api.ts';
import { formatDateId, formatNumber } from '../../utils/format.ts';
import {
adgStatus,
formatWeight,
getTargetWeightForAge,
type WeightStatus,
uniformityStatus,
weightDeviationStatus,
} from '../../utils/weight.ts';
const statusText: Record<WeightStatus, string> = {
ok: 'text-green-600',
warning: 'text-yellow-600',
critical: 'text-red-600',
};
const StatItem: React.FC<{
icon: React.ReactNode;
label: string;
value: string;
status: WeightStatus;
}> = ({ icon, label, value, status }) => (
<div className="flex items-center p-2 rounded-lg hover:bg-gray-50">
<div className="text-2xl text-gray-400 w-12">{icon}</div>
<div>
<p className="text-sm text-gray-600 font-medium">{label}</p>
<p className={`text-2xl font-bold ${statusText[status]}`}>
<span className="text-base font-medium">{value}</span>
</p>
</div>
</div>
);
interface WeightStatsProps {
latest: LatestAvg | null;
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;