328 lines
12 KiB
TypeScript
328 lines
12 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, 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;
|
|
const displayDateIso = latest?.date ?? visibleThrough ?? todayIso();
|
|
const latestManual =
|
|
sortedManualRows.find((row) => row.date === displayDateIso) ??
|
|
sortedManualRows[sortedManualRows.length - 1] ??
|
|
null;
|
|
|
|
// Display date follows the latest snapshot; without any snapshot, fall back to today.
|
|
// Cycle day is capped so data never extends past the cycle's planned duration.
|
|
const displayDate = latest?.date ?? visibleThrough ?? todayIso();
|
|
const currentDay = useMemo(() => {
|
|
if (!selectedCycle) return 0;
|
|
const rawDay = cycleDayForDate(selectedCycle.start_date, displayDate);
|
|
return Math.min(rawDay, Math.max(0, selectedCycle.total_days - 1));
|
|
}, [selectedCycle, displayDate]);
|
|
|
|
const handleSync = useCallback(async () => {
|
|
if (!selectedCycle) return;
|
|
setSyncing(true);
|
|
setError(null);
|
|
setNotice(null);
|
|
try {
|
|
await api.karungs.request({ cycle_id: selectedCycle.id, section: 'today' });
|
|
setNotice('Sinkron karung-web-admin berhasil.');
|
|
await reload(selectedCycle.id);
|
|
} catch (err) {
|
|
setError(errorMessage(err));
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
}, [selectedCycle, reload]);
|
|
|
|
const sortedCycles = useMemo(() => {
|
|
return [...cycles].sort(
|
|
(a, b) => new Date(b.start_date).getTime() - new Date(a.start_date).getTime()
|
|
);
|
|
}, [cycles]);
|
|
|
|
const formatCycleDateRange = (cycle: Cycle) => {
|
|
const start = formatDateId(cycle.start_date);
|
|
if (cycle.end_date) return `${start} - ${formatDateId(cycle.end_date)}`;
|
|
return `${start} - ${formatDateId(addDaysIso(cycle.start_date, cycle.total_days))}`;
|
|
};
|
|
|
|
const lastUpdated = useMemo(() => {
|
|
if (!latest) return null;
|
|
const timestamp = latest.updated_at || latest.created_at;
|
|
if (!timestamp) return null;
|
|
const date = new Date(timestamp);
|
|
if (Number.isNaN(date.getTime())) return null;
|
|
return `${date.toLocaleDateString('id-ID', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
})} ${date.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}`;
|
|
}, [latest]);
|
|
|
|
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
|
|
|
return (
|
|
<div>
|
|
{/* Page Title */}
|
|
<div className="flex items-center mb-6">
|
|
<div className="bg-red-600 text-white p-3 rounded-lg mr-4">
|
|
<i className="fa-solid fa-boxes-stacked text-2xl sm:text-3xl"></i>
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl sm:text-3xl font-bold text-gray-800">Hitung Karung</h1>
|
|
<p className="mt-1 text-gray-600">Monitoring karung masuk dan karung dituang per hari</p>
|
|
{lastUpdated && (
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
<i className="fa-solid fa-clock mr-1"></i>
|
|
Terakhir diperbarui: {lastUpdated}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<button
|
|
onClick={handleSync}
|
|
disabled={syncing || selectedCycle.status !== 'active'}
|
|
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-60 hover:bg-red-700 transition-colors"
|
|
>
|
|
{syncing ? (
|
|
<>
|
|
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
|
Sync...
|
|
</>
|
|
) : (
|
|
<>
|
|
<i className="fa-solid fa-arrows-rotate mr-2"></i>
|
|
Sync hari ini
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cycle Selector */}
|
|
<div className="mb-6">
|
|
<div className="relative inline-block">
|
|
<button
|
|
onClick={() => setShowCyclePicker(!showCyclePicker)}
|
|
className="flex items-center gap-3 px-4 py-2 bg-white border border-gray-300 rounded-lg shadow-sm hover:bg-gray-50 transition-colors min-w-[340px] sm:min-w-[400px]"
|
|
>
|
|
<i className="fa-solid fa-rotate text-blue-600"></i>
|
|
<div className="flex-1 text-left">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold text-gray-800">
|
|
Siklus {formatCycleDateRange(selectedCycle)}
|
|
</span>
|
|
{selectedCycle.status === 'active' && (
|
|
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded">
|
|
Aktif
|
|
</span>
|
|
)}
|
|
{selectedCycle.status === 'closed' && (
|
|
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded">
|
|
Selesai
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<i
|
|
className={`fa-solid fa-chevron-down text-gray-400 text-xs transition-transform ${showCyclePicker ? 'rotate-180' : ''}`}
|
|
></i>
|
|
</button>
|
|
|
|
{/* Cycle Picker Dropdown */}
|
|
{showCyclePicker && (
|
|
<div className="absolute top-full left-0 mt-2 bg-white border border-gray-200 rounded-lg shadow-lg z-10 min-w-[340px] sm:min-w-[400px] max-h-[400px] overflow-y-auto">
|
|
{sortedCycles.map((cycle) => (
|
|
<button
|
|
key={cycle.id}
|
|
onClick={() => {
|
|
setSelectedCycleId(cycle.id);
|
|
setShowCyclePicker(false);
|
|
}}
|
|
className={`w-full px-4 py-3 text-left hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0 ${
|
|
cycle.id === selectedCycle.id ? 'bg-blue-50' : ''
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
{cycle.status === 'active' && (
|
|
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
|
)}
|
|
<span className="text-sm font-semibold text-gray-800">
|
|
Siklus {formatCycleDateRange(cycle)}
|
|
</span>
|
|
{cycle.status === 'active' && (
|
|
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded">
|
|
Aktif
|
|
</span>
|
|
)}
|
|
{cycle.status === 'closed' && (
|
|
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded">
|
|
Selesai
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-4">
|
|
<Banner kind="error">{error}</Banner>
|
|
</div>
|
|
)}
|
|
{notice && (
|
|
<div className="mb-4">
|
|
<Banner kind="success">{notice}</Banner>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<Spinner />
|
|
) : (
|
|
<div className="space-y-8">
|
|
{/* AI Insight */}
|
|
<section>
|
|
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
|
<i className="fa-solid fa-brain text-red-600 mr-2"></i>
|
|
AI Insight Hitung Karung
|
|
</h2>
|
|
{insights.length === 0 ? (
|
|
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
|
) : (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{insights.slice(0, 4).map((item) => (
|
|
<article
|
|
key={item.id}
|
|
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-gray-400">
|
|
{item.section}
|
|
</p>
|
|
<h3 className="font-semibold text-gray-800 mt-1">
|
|
{item.alert || 'Insight'}
|
|
</h3>
|
|
</div>
|
|
<span className="text-xs text-gray-400">{item.session}</span>
|
|
</div>
|
|
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
|
{item.insight_text}
|
|
</p>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Saldo Awal */}
|
|
<InitialBalanceCard
|
|
cycle={selectedCycle}
|
|
onSaved={(saved) => {
|
|
updateCycle(saved.cycle, { feed_initial_balance: saved.feed_initial_balance });
|
|
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;
|