Files
dashboard-cpsp/components/FeedSackCountingPage.tsx
T

341 lines
13 KiB
TypeScript

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, updateCycle } =
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
</>
)}
</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">
{/* AI Insight */}
<section>
<h2 className="text-lg font-bold text-gray-800 mb-3">
<i className="fa-solid fa-brain text-red-600 mr-2"></i>
AI Insight Hitung Karung
</h2>
{insights.length === 0 ? (
<Banner>Belum ada insight untuk siklus ini.</Banner>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{insights.slice(0, 4).map((item) => (
<article
key={item.id}
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">
{item.section}
</p>
<h3 className="font-semibold text-gray-800 mt-1">
{item.alert || 'Insight'}
</h3>
</div>
<span className="text-xs text-gray-400">{item.session}</span>
</div>
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
{item.insight_text}
</p>
</article>
))}
</div>
)}
</section>
{/* Saldo Awal */}
<InitialBalanceCard
cycle={selectedCycle}
onSaved={(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;