462 lines
17 KiB
TypeScript
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;
|