228 lines
10 KiB
TypeScript
228 lines
10 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { useAppContext } from '../context/AppContext.tsx';
|
|
import { FileInvoiceIcon, BrainIcon } from './icons/Icons.tsx';
|
|
import WeighingRecordCard from './manualWeighing/WeighingRecordCard.tsx';
|
|
import { getTargetWeightForDay } from '../utils/weightUtils.ts';
|
|
import { ManualWeighingRecord } from '../types.ts';
|
|
import AiWeighingAnalysisModal from './manualWeighing/AiWeighingAnalysisModal.tsx';
|
|
import ManualWeighingStats from './manualWeighing/ManualWeighingStats.tsx';
|
|
import ManualWeighingAnalysis from './manualWeighing/ManualWeighingAnalysis.tsx';
|
|
import AiInsightCard from './shared/AiInsightCard.tsx';
|
|
|
|
const ManualWeighingPage: React.FC = () => {
|
|
const { manualWeighingData, cycleData, apiMode } = useAppContext();
|
|
|
|
const recordsByDate = useMemo(() => {
|
|
const groups: { [key: string]: (typeof manualWeighingData)[0] } = {};
|
|
manualWeighingData.forEach((record) => {
|
|
const group = groups[record.date] ?? (groups[record.date] = { ...record, sessions: [] });
|
|
group.sessions.push(...record.sessions);
|
|
});
|
|
return Object.values(groups).sort(
|
|
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
|
);
|
|
}, [manualWeighingData]);
|
|
|
|
const [openAccordions, setOpenAccordions] = useState<Record<string, boolean>>(() => {
|
|
const first = recordsByDate[0];
|
|
return first ? { [first.date]: true } : {}; // Open first one by default
|
|
});
|
|
|
|
const [isAnalysisModalOpen, setIsAnalysisModalOpen] = useState(false);
|
|
const [selectedRecord, setSelectedRecord] = useState<{
|
|
record: ManualWeighingRecord;
|
|
dayOfCycle: number;
|
|
} | null>(null);
|
|
|
|
const handleToggle = (date: string) => {
|
|
setOpenAccordions((prev) => ({ ...prev, [date]: !prev[date] }));
|
|
};
|
|
|
|
const handleAnalyze = (record: ManualWeighingRecord, dayOfCycle: number) => {
|
|
setSelectedRecord({ record, dayOfCycle });
|
|
setIsAnalysisModalOpen(true);
|
|
};
|
|
|
|
const expandAll = () => {
|
|
const allOpen = recordsByDate.reduce(
|
|
(acc, record) => {
|
|
acc[record.date] = true;
|
|
return acc;
|
|
},
|
|
{} as Record<string, boolean>
|
|
);
|
|
setOpenAccordions(allOpen);
|
|
};
|
|
|
|
const collapseAll = () => {
|
|
setOpenAccordions({});
|
|
};
|
|
|
|
return (
|
|
<main className="px-4 sm:px-6 lg:px-8 pb-8">
|
|
<div className="flex items-center mb-6">
|
|
<FileInvoiceIcon className="text-3xl text-red-600 mr-4" />
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-800">Pencatatan Timbangan Manual</h1>
|
|
<p className="mt-1 text-gray-600">
|
|
Arsip dan analisis data timbangan manual untuk kalibrasi dan referensi.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{recordsByDate.length > 0 ? (
|
|
<>
|
|
{apiMode === 'DEMO' && (
|
|
<div className="mb-8">
|
|
<AiInsightCard title="AI Insight: Analisis Pertumbuhan Jangka Panjang">
|
|
<p>
|
|
Analisis tren pertumbuhan dari data manual menunjukkan Average Daily Gain (ADG)
|
|
yang sempat tertinggal dari target, terutama di fase grower. Namun, terjadi
|
|
percepatan pertumbuhan signifikan setelah hari ke-34. Meskipun demikian, bobot
|
|
rata-rata aktual (misal: <strong>1.972g</strong> pada hari ke-35) masih berada di
|
|
bawah target standar Ross 308 (<strong>2.237g</strong>). Ini mengindikasikan
|
|
adanya ketertinggalan kumulatif dari fase awal siklus. Rekomendasi: Fokus pada
|
|
optimalisasi kondisi brooding pada siklus berikutnya untuk memastikan target awal
|
|
tercapai.
|
|
</p>
|
|
</AiInsightCard>
|
|
</div>
|
|
)}
|
|
<ManualWeighingAnalysis records={recordsByDate} cycleData={cycleData} />
|
|
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center my-6 gap-4">
|
|
<h2 className="text-2xl font-bold text-gray-800">Rincian Data Harian</h2>
|
|
<div className="flex items-center space-x-2">
|
|
<button
|
|
onClick={expandAll}
|
|
className="text-sm text-gray-600 font-medium px-3 py-1.5 rounded-md hover:bg-gray-100 transition-colors"
|
|
>
|
|
Buka Semua
|
|
</button>
|
|
<button
|
|
onClick={collapseAll}
|
|
className="text-sm text-gray-600 font-medium px-3 py-1.5 rounded-md hover:bg-gray-100 transition-colors"
|
|
>
|
|
Tutup Semua
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{recordsByDate.map((record) => {
|
|
const recordDate = new Date(record.date);
|
|
const cycleStartDate = new Date(cycleData.startDate);
|
|
recordDate.setHours(0, 0, 0, 0);
|
|
cycleStartDate.setHours(0, 0, 0, 0);
|
|
|
|
const diffTime = recordDate.getTime() - cycleStartDate.getTime();
|
|
const dayOfCycle = Math.floor(diffTime / (1000 * 60 * 60 * 24)) + 14; // Data starts from day 14
|
|
const targetWeight = getTargetWeightForDay(dayOfCycle);
|
|
|
|
return (
|
|
<div
|
|
key={record.date}
|
|
className="bg-white rounded-xl shadow-sm border border-gray-200"
|
|
>
|
|
<div className="w-full flex justify-between items-center p-4 text-left">
|
|
<button
|
|
onClick={() => handleToggle(record.date)}
|
|
className="flex-grow text-left group"
|
|
>
|
|
<h2 className="text-lg font-bold text-gray-800 group-hover:text-red-700 transition-colors flex items-center flex-wrap gap-x-3 gap-y-2">
|
|
<span>
|
|
{new Date(record.date).toLocaleDateString('id-ID', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})}
|
|
</span>
|
|
<span className="text-sm font-semibold text-gray-500 bg-gray-100 px-3 py-1 rounded-full">
|
|
Hari ke-{dayOfCycle}
|
|
</span>
|
|
{record.aiAnalysisResult && (
|
|
<span
|
|
title="Analisa AI sudah disimpan"
|
|
className="text-green-700 bg-green-100 px-3 py-1 rounded-full text-xs font-bold flex items-center"
|
|
>
|
|
<i className="fa-solid fa-check-circle mr-1.5"></i>
|
|
TERANALISA
|
|
</span>
|
|
)}
|
|
</h2>
|
|
<p className="text-sm text-gray-500 mt-1">Sumber: {record.sourceDocument}</p>
|
|
</button>
|
|
<div className="flex items-center space-x-4 pl-4">
|
|
{record.aiAnalysisResult ? (
|
|
<button
|
|
onClick={() => handleAnalyze(record, dayOfCycle)}
|
|
className="flex items-center space-x-2 bg-green-50 text-green-700 px-3 py-2 rounded-lg hover:bg-green-100 transition duration-300 shadow-sm font-semibold text-sm"
|
|
title="Analisa AI sudah disimpan. Klik untuk melihat kembali."
|
|
>
|
|
<i className="fa-solid fa-check-circle"></i>
|
|
<span>Lihat Analisa</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => handleAnalyze(record, dayOfCycle)}
|
|
className="flex items-center space-x-2 bg-red-50 text-red-600 px-3 py-2 rounded-lg hover:bg-red-100 transition duration-300 shadow-sm font-semibold text-sm"
|
|
>
|
|
<BrainIcon />
|
|
<span>Analisa AI</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => handleToggle(record.date)}
|
|
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100"
|
|
>
|
|
<i
|
|
className={`fa-solid fa-chevron-down text-gray-500 transform transition-transform ${openAccordions[record.date] ? 'rotate-180' : ''}`}
|
|
></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{openAccordions[record.date] && (
|
|
<div className="p-4 border-t border-gray-200">
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
<div className="lg:col-span-1">
|
|
<ManualWeighingStats record={record} dayOfCycle={dayOfCycle} />
|
|
</div>
|
|
<div className="lg:col-span-2 grid grid-cols-1 xl:grid-cols-2 gap-6">
|
|
{record.sessions.map((session) => (
|
|
<WeighingRecordCard
|
|
key={session.id}
|
|
session={session}
|
|
targetWeight={targetWeight}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-center py-20 bg-white rounded-xl shadow-sm">
|
|
<i className="fa-solid fa-file-excel text-5xl text-gray-300"></i>
|
|
<p className="mt-4 text-lg font-semibold text-gray-600">Tidak ada data tersedia</p>
|
|
<p className="text-sm text-gray-500">Silakan unggah data timbangan manual.</p>
|
|
</div>
|
|
)}
|
|
{isAnalysisModalOpen && selectedRecord && (
|
|
<AiWeighingAnalysisModal
|
|
isOpen={isAnalysisModalOpen}
|
|
onClose={() => setIsAnalysisModalOpen(false)}
|
|
record={selectedRecord.record}
|
|
dayOfCycle={selectedRecord.dayOfCycle}
|
|
/>
|
|
)}
|
|
</main>
|
|
);
|
|
};
|
|
|
|
export default ManualWeighingPage;
|