Files
dashboard-cpsp/components/FeedSackCountingPage.tsx
T

405 lines
15 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { api, errorMessage } from '../services/apiClient.ts';
import type { 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 { buildFeedSackInsightContext } from '../utils/insightContextBuilders.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';
import { PageAiInsight } from './shared/PageAiInsight.tsx';
const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle, selectedKandang, selectedSite, cycles, setSelectedCycleId, updateCycle } =
useFarm();
const [rows, setRows] = useState<Karung[]>([]);
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
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, manualList] = await Promise.all([
api.karungs.list({ cycle_id: cycleId }),
api.manualInputs.list({ cycle_id: cycleId }),
]);
setRows(karungRows);
setManualRows(manualList);
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!selectedCycle) {
setRows([]);
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 boundDate = selectedCycle.end_date ?? selectedCycle.visible_through_date ?? todayIso();
return cycleDayForDate(selectedCycle.start_date, boundDate);
}, [selectedCycle]);
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]);
const insightContext = useMemo(() => {
if (!selectedKandang || !selectedCycle) return null;
const manualByDate = new Map(sortedManualRows.map((row) => [row.date, row]));
const dailyRows = sortedRows.map((row) => {
const hari = cycleDayForDate(selectedCycle.start_date, row.date);
const manual = manualByDate.get(row.date);
return {
day: hari,
masuk: row.in_today,
dituang: row.feed_use_today,
keluar: row.out_today,
masukManual: manual?.feed_in_manual ?? null,
dituangManual: manual?.feed_use_manual ?? null,
};
});
return buildFeedSackInsightContext({
kandangId: selectedKandang.id,
kandangName: selectedKandang.kandang_name,
cycleId: selectedCycle.id,
totalDays: selectedCycle.total_days,
dailyRows,
saldo_awal_karung: selectedCycle.feed_initial_balance ?? null,
total_karung_masuk_iot: latest?.in_total ?? null,
total_karung_dituang_iot: latest?.feed_use_total ?? null,
total_karung_keluar_iot: latest?.out_total ?? null,
total_karung_masuk_manual: latestManual?.feed_in_manual_total ?? null,
total_karung_dituang_manual: latestManual?.feed_use_manual_total ?? null,
});
}, [selectedKandang, selectedCycle, sortedRows, sortedManualRows, latest, latestManual]);
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
</>
)}
</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 === 'pending_close' && (
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
Menunggu GM
</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 === 'pending_close' && (
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
Menunggu GM
</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">
{selectedKandang && insightContext ? (
<PageAiInsight
topic="hitung_karung"
cacheKey={`pakan-k${selectedKandang.id}-c${selectedCycle.id}-day${currentDay}-rows${sortedRows.length}`}
contextData={insightContext}
chartSpec={{
type: 'trend',
historyKey: 'tren_harian',
title: 'Karung dituang vs masuk per hari',
unit: 'karung',
series: [
{ field: 'dituang', label: 'Dituang (IOT)', color: '#dc2626' },
{ field: 'masuk', label: 'Masuk (IOT)', color: '#3b82f6' },
{ field: 'masukManual', label: 'Masuk (Manual)', color: '#0ea5e9' },
],
}}
scalarScope={{
historyKey: 'tren_harian',
derive: {
karungMasuk_karung: {
field: 'masuk',
agg: 'sum',
replaces: 'total_karung_masuk_iot',
},
karungDituang_karung: {
field: 'dituang',
agg: 'sum',
replaces: 'total_karung_dituang_iot',
},
karungKeluar_karung: { field: 'keluar', agg: 'sum' },
karungMasukManual_karung: {
field: 'masukManual',
agg: 'sum',
replaces: 'total_karung_masuk_manual',
},
karungDituangManual_karung: {
field: 'dituangManual',
agg: 'sum',
replaces: 'total_karung_dituang_manual',
},
rataKarungDituangPerHari_karung: {
field: 'dituang',
agg: 'mean',
replaces: 'rata_karung_per_hari',
},
},
drop: [
'total_karung_masuk_iot',
'total_karung_dituang_iot',
'total_karung_masuk_manual',
'total_karung_dituang_manual',
'rata_karung_per_hari',
],
}}
/>
) : (
<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>
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
</section>
)}
{/* Saldo Awal */}
<InitialBalanceCard
cycle={selectedCycle}
onSaved={(saved) => {
updateCycle(saved.cycle, {
feed_initial_balance: saved.feed_initial_balance,
feed_initial_balance_date: saved.feed_initial_balance_date,
});
void reload(selectedCycle.id);
}}
/>
{/* 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;