Files
dashboard/components/FeedSackCountingPage.tsx
T
2026-07-29 10:48:03 +07:00

462 lines
17 KiB
TypeScript

import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { SackIcon } from './icons/Icons.tsx';
import FeedStats from './feedSack/FeedStats.tsx';
import FeedTrendChart from './feedSack/FeedTrendChart.tsx';
import FeedDailySummaryTable from './feedSack/FeedDailySummaryTable.tsx';
import FeedSackComparisonCharts from './feedSack/FeedSackComparisonCharts.tsx';
import FeedSaldoCard from './feedSack/FeedSaldoCard.tsx';
import InitialBalanceCard from './feedSack/InitialBalanceCard.tsx';
import ColumnConfigModal from './feedSack/ColumnConfigModal.tsx';
import { useAppContext } from '../context/AppContext.tsx';
import {
FeedSackColumnConfig,
DEFAULT_FEED_SACK_COLUMNS,
CycleFeedInitialBalance,
} from '../types/counting.ts';
import { normalizeFeedSackColumnConfigs } from '../utils/feedSackMapping.ts';
import { getEffectiveCycleDay } from '../utils/dateFilter.ts';
import { apiClient } from '../services/apiService.ts';
import { View, CycleData } from '../types.ts';
import { PageAiInsight } from './shared/PageAiInsight.tsx';
interface FeedSackCountingPageProps {
setView?: (view: View) => void;
}
const FeedSackCountingPage: React.FC<FeedSackCountingPageProps> = ({ setView }) => {
const {
feedSackHistory,
manualFeedSackEntries,
fetchFeedSackData,
fetchManualFeedSackData,
cycleData,
cycles,
availableFeedSackCameras,
feedSackDataLastUpdate,
isFeedSackDataLoading,
refreshFeedSackData,
effectiveDate,
} = useAppContext();
// Column config state (loaded from DB)
const [columnConfig, setColumnConfig] =
useState<FeedSackColumnConfig[]>(DEFAULT_FEED_SACK_COLUMNS);
const columnConfigRef = useRef(columnConfig);
const [showColumnModal, setShowColumnModal] = useState(false);
// Keep ref in sync so handleSaveColumns always reads the latest value
// without needing columnConfig as a useCallback dependency.
useEffect(() => {
columnConfigRef.current = columnConfig;
}, [columnConfig]);
// Load column config from database on mount
useEffect(() => {
apiClient
.getFeedSackColumnConfig()
.then((config: FeedSackColumnConfig[]) => {
if (config && config.length > 0) {
const normalizedConfig: FeedSackColumnConfig[] = normalizeFeedSackColumnConfigs(config);
setColumnConfig(normalizedConfig);
}
})
.catch((error: unknown) => {
console.error('Failed to load column config:', error);
});
}, []);
const handleSaveColumns = useCallback(async (newColumns: FeedSackColumnConfig[]) => {
const previousConfig = columnConfigRef.current;
setColumnConfig(newColumns);
try {
await apiClient.saveFeedSackColumnConfig(newColumns);
} catch (error) {
console.error('Failed to save column config:', error);
setColumnConfig(previousConfig);
}
}, []);
// Cycle selection state - default to active cycle
const [selectedCycleId, setSelectedCycleId] = useState<string>(cycleData.id);
const [showCyclePicker, setShowCyclePicker] = useState(false);
const [initialBalance, setInitialBalance] = useState<CycleFeedInitialBalance | null>(null);
// Get selected cycle data
const selectedCycle = useMemo(() => {
return cycles.find((c) => c.id === selectedCycleId) || cycleData;
}, [cycles, selectedCycleId, cycleData]);
// Compute the display end date for the selected cycle.
// Active cycle follows the homepage D-1 rule; completed cycles use endDate/currentDay.
// Always cap at totalDays - 1 so data never extends past Estimasi Panen.
const displayCurrentDay = useMemo(() => {
const lastCycleDay = Math.max(0, (selectedCycle.totalDays || 1) - 1);
if (selectedCycle.status === 'Active') {
const rawDay = Math.max(0, getEffectiveCycleDay(selectedCycle.startDate));
return Math.min(rawDay, lastCycleDay);
}
if (selectedCycle.endDate) {
const start = new Date(selectedCycle.startDate);
start.setHours(0, 0, 0, 0);
const cycleEnd = new Date(selectedCycle.endDate);
cycleEnd.setHours(0, 0, 0, 0);
const dayFromEnd = Math.floor((cycleEnd.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
return Math.min(Math.max(0, dayFromEnd), lastCycleDay);
}
return Math.min(Math.max(0, selectedCycle.currentDay ?? 0), lastCycleDay);
}, [
selectedCycle.currentDay,
selectedCycle.endDate,
selectedCycle.startDate,
selectedCycle.status,
selectedCycle.totalDays,
effectiveDate,
]);
const displayDate = useMemo(() => {
const start = new Date(selectedCycle.startDate);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + displayCurrentDay);
return end;
}, [selectedCycle.startDate, displayCurrentDay]);
// Compute date range from selected cycle
const { startDate, endDate } = useMemo(() => {
const start = new Date(selectedCycle.startDate);
start.setHours(0, 0, 0, 0);
const end = new Date(displayDate);
end.setHours(23, 59, 59, 999);
return { startDate: start, endDate: end };
}, [selectedCycle.startDate, displayDate]);
// Update selected cycle when active cycle changes
useEffect(() => {
setSelectedCycleId(cycleData.id);
}, [cycleData.id]);
// Fetch initial balance when cycle changes
useEffect(() => {
const fetchInitialBalance = async () => {
try {
const balance = await apiClient.getCycleFeedInitialBalance(selectedCycleId);
setInitialBalance(balance);
} catch (error) {
console.error('Failed to fetch initial balance:', error);
setInitialBalance(null);
}
};
fetchInitialBalance();
}, [selectedCycleId]);
// Fetch feed sack data on component mount
useEffect(() => {
fetchFeedSackData();
}, [fetchFeedSackData]);
// Fetch manual entries when cycle/date range changes
useEffect(() => {
const startDateStr = startDate.toISOString().split('T')[0];
const endDateStr = endDate.toISOString().split('T')[0];
fetchManualFeedSackData(startDateStr, endDateStr);
}, [startDate, endDate, fetchManualFeedSackData]);
// Filter data based on date range
const filteredData = useMemo(() => {
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
return feedSackHistory.filter((log) => {
const logDate = new Date(log.timestamp);
return logDate >= start && logDate <= end;
});
}, [feedSackHistory, startDate, endDate]);
// Handler for saving initial balance
const handleSaveInitialBalance = useCallback(
async (saldoAwal: number) => {
try {
const balance = await apiClient.saveCycleFeedInitialBalance(
selectedCycleId,
saldoAwal,
'system' // TODO: Replace with actual user
);
setInitialBalance(balance);
} catch (error) {
console.error('Failed to save initial balance:', error);
throw error;
}
},
[selectedCycleId]
);
// Handler for saving manual feed sack entries
const handleSaveManualEntry = useCallback(
async (cameraName: string, count: number, date: Date, keterangan?: string) => {
// Convert to local date string without timezone conversion
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
await apiClient.saveManualFeedSackEntry(dateStr, cameraName, count, keterangan);
// Refresh manual entries for current date range
const startYear = startDate.getFullYear();
const startMonth = String(startDate.getMonth() + 1).padStart(2, '0');
const startDay = String(startDate.getDate()).padStart(2, '0');
const startDateStr = `${startYear}-${startMonth}-${startDay}`;
const endYear = endDate.getFullYear();
const endMonth = String(endDate.getMonth() + 1).padStart(2, '0');
const endDay = String(endDate.getDate()).padStart(2, '0');
const endDateStr = `${endYear}-${endMonth}-${endDay}`;
await fetchManualFeedSackData(startDateStr, endDateStr);
},
[startDate, endDate, fetchManualFeedSackData]
);
// Sort cycles by start date (newest first)
const sortedCycles = useMemo(() => {
return [...cycles].sort((a, b) => {
return new Date(b.startDate).getTime() - new Date(a.startDate).getTime();
});
}, [cycles]);
const formatCycleDateRange = (cycle: CycleData) => {
const start = new Date(cycle.startDate);
const startStr = start.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
if (cycle.endDate) {
const end = new Date(cycle.endDate);
const endStr = end.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
return `${startStr} - ${endStr}`;
}
// Calculate end date from totalDays
const end = new Date(start);
end.setDate(end.getDate() + cycle.totalDays);
const endStr = end.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
return `${startStr} - ${endStr}`;
};
return (
<main className="px-4 sm:px-6 lg:px-8 pb-8">
<div className="max-w-7xl mx-auto">
{/* Page Title */}
<div className="flex items-center mb-6">
<SackIcon className="text-3xl text-red-600 mr-4" />
<div>
<h1 className="text-3xl font-bold text-gray-800">Penghitungan Karung Pakan</h1>
<p className="mt-1 text-gray-600">
Monitoring karung masuk dan karung dituang per hari
</p>
{feedSackDataLastUpdate && (
<p className="text-xs text-gray-500 mt-1">
Terakhir diperbarui: {feedSackDataLastUpdate.toLocaleTimeString('id-ID')}
</p>
)}
</div>
<div className="ml-auto flex items-center gap-2">
<button
onClick={refreshFeedSackData}
disabled={isFeedSackDataLoading}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
title="Refresh data dari server"
>
<i className={`fa-solid fa-refresh ${isFeedSackDataLoading ? 'fa-spin' : ''}`}></i>
<span>{isFeedSackDataLoading ? 'Memuat...' : 'Refresh'}</span>
</button>
<button
onClick={() => {
if (setView) {
setView('kandangSettings');
}
}}
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium text-gray-700 transition-colors"
>
<i className="fa-solid fa-sliders"></i>
<span>Edit Kolom</span>
</button>
</div>
</div>
{/* Cycle Selector */}
<div className="mb-6">
<div className="relative">
<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-[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">{selectedCycle.id}</span>
{selectedCycle.status === 'Active' && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded">
Active
</span>
)}
</div>
<span className="text-xs text-gray-500">{formatCycleDateRange(selectedCycle)}</span>
</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-[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 === selectedCycleId ? '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">{cycle.id}</span>
{cycle.status === 'Active' && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded">
Active
</span>
)}
{cycle.status === 'Completed' && (
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded">
Completed
</span>
)}
</div>
<span className="text-xs text-gray-500">{formatCycleDateRange(cycle)}</span>
</button>
))}
</div>
)}
</div>
</div>
{/* Single Column Layout */}
<div className="space-y-8">
{/* AI Insight Hitung Karung */}
{!isFeedSackDataLoading && filteredData.length > 0 && (
<PageAiInsight
topic="hitung_karung"
cacheKey={`pakan-${selectedCycleId}-day${displayCurrentDay}-entries${filteredData.length}`}
contextData={{
siklus: selectedCycleId,
hari_ke: displayCurrentDay,
total_catatan_iot: filteredData.length,
saldo_awal_karung: initialBalance?.saldoAwal ?? null,
total_karung_masuk_iot: filteredData.reduce(
(sum: number, d: any) => sum + (d.countIn ?? 0),
0
),
total_karung_dituang_iot: filteredData.reduce(
(sum: number, d: any) => sum + (d.countOut ?? 0),
0
),
rata_karung_per_hari:
displayCurrentDay > 0
? Math.round(
(filteredData.reduce((sum: number, d: any) => sum + (d.countOut ?? 0), 0) /
displayCurrentDay) *
10
) / 10
: null,
total_entry_manual: manualFeedSackEntries.length,
catatan:
'Karung masuk = stok diterima, karung dituang = pakan dikonsumsi oleh ayam.',
}}
/>
)}
<FeedStats
data={filteredData}
manualEntries={manualFeedSackEntries}
columnConfig={columnConfig}
effectiveDate={displayDate}
/>
{/* Initial Balance Card */}
<InitialBalanceCard
cycleId={selectedCycleId}
initialBalance={initialBalance}
onSave={handleSaveInitialBalance}
/>
{/* Saldo Karung Card */}
<FeedSaldoCard
iotData={filteredData}
columnConfig={columnConfig}
startDate={startDate}
saldoAwal={initialBalance?.saldoAwal || 0}
effectiveDate={displayDate}
/>
<FeedTrendChart
data={filteredData}
cycleStartDate={startDate}
currentDay={displayCurrentDay}
columnConfig={columnConfig}
/>
{/* Manual vs IOT Comparison Charts */}
<FeedSackComparisonCharts
iotData={filteredData}
manualEntries={manualFeedSackEntries}
cycleStartDate={startDate}
currentDay={displayCurrentDay}
columnConfig={columnConfig}
/>
<FeedDailySummaryTable
iotData={filteredData}
manualEntries={manualFeedSackEntries}
cycleStartDate={startDate}
currentDay={displayCurrentDay}
columnConfig={columnConfig}
onSaveManualEntry={handleSaveManualEntry}
/>
</div>
</div>
<ColumnConfigModal
isOpen={showColumnModal}
onClose={() => setShowColumnModal(false)}
onSave={handleSaveColumns}
columns={columnConfig}
availableCameras={availableFeedSackCameras}
/>
</main>
);
};
export default FeedSackCountingPage;