first commit
This commit is contained in:
commit
b54624be96
226 files changed
+108840
No files matched your search
@@ -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;
|
||||
Reference in new issue
Block a user