266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
import React, { useMemo, useRef, useState } from 'react';
|
|
import type { Karung, ManualInput } from '../../types/api.ts';
|
|
import { formatNumber } from '../../utils/format.ts';
|
|
|
|
type SortOrder = 'asc' | 'desc';
|
|
|
|
interface FeedDailySummaryTableProps {
|
|
rows: Karung[];
|
|
manualRows: ManualInput[];
|
|
cycleStartDate: string;
|
|
currentDay: number;
|
|
}
|
|
|
|
const accuracyOf = (manual: number, iot: number): string => {
|
|
if (manual === 0 && iot === 0) return '100.0%';
|
|
if (manual === 0 || iot === 0) return '0.0%';
|
|
const smaller = Math.min(manual, iot);
|
|
const larger = Math.max(manual, iot);
|
|
return `${((smaller / larger) * 100).toFixed(1)}%`;
|
|
};
|
|
|
|
const accuracyClass = (manual: number, iot: number): string => {
|
|
if (manual === 0 && iot === 0) return 'text-green-600';
|
|
if (manual === 0 || iot === 0) return 'text-red-600';
|
|
const percentage = (Math.min(manual, iot) / Math.max(manual, iot)) * 100;
|
|
if (percentage >= 95) return 'text-green-600';
|
|
if (percentage >= 80) return 'text-yellow-600';
|
|
return 'text-red-600';
|
|
};
|
|
|
|
const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
|
|
rows,
|
|
manualRows,
|
|
cycleStartDate,
|
|
currentDay,
|
|
}) => {
|
|
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
|
|
|
const masukRef = useRef<HTMLDivElement>(null);
|
|
const tuangRef = useRef<HTMLDivElement>(null);
|
|
const keluarRef = useRef<HTMLDivElement>(null);
|
|
|
|
const scrollToSection = (ref: React.RefObject<HTMLDivElement | null>) => {
|
|
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
};
|
|
|
|
const aggregatedData = useMemo(() => {
|
|
const cycleStart = new Date(`${cycleStartDate}T00:00:00`);
|
|
const iotByDate = new Map<string, Karung>();
|
|
for (const row of rows) iotByDate.set(row.date, row);
|
|
const manualByDate = new Map<string, ManualInput>();
|
|
for (const row of manualRows) manualByDate.set(row.date, row);
|
|
|
|
const result: {
|
|
day: number;
|
|
date: Date;
|
|
dateFormatted: string;
|
|
iot: Karung | null;
|
|
manual: ManualInput | null;
|
|
}[] = [];
|
|
for (let d = 0; d <= currentDay; d++) {
|
|
const date = new Date(cycleStart);
|
|
date.setDate(cycleStart.getDate() + d);
|
|
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
|
|
date.getDate()
|
|
).padStart(2, '0')}`;
|
|
result.push({
|
|
day: d,
|
|
date,
|
|
dateFormatted: date.toLocaleDateString('id-ID', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
}),
|
|
iot: iotByDate.get(key) ?? null,
|
|
manual: manualByDate.get(key) ?? null,
|
|
});
|
|
}
|
|
return result;
|
|
}, [rows, manualRows, cycleStartDate, currentDay]);
|
|
|
|
const sortedData = useMemo(() => {
|
|
const sorted = [...aggregatedData].sort((a, b) => a.day - b.day);
|
|
return sortOrder === 'desc' ? sorted.reverse() : sorted;
|
|
}, [aggregatedData, sortOrder]);
|
|
|
|
if (aggregatedData.length === 0 || (rows.length === 0 && manualRows.length === 0)) {
|
|
return (
|
|
<div className="bg-white p-6 rounded-xl shadow-sm">
|
|
<h2 className="text-xl font-bold mb-4">Rincian Harian Karung IOT</h2>
|
|
<div className="h-60 flex items-center justify-center">
|
|
<div className="text-center text-gray-500">
|
|
<i className="fa-solid fa-table text-5xl text-gray-300 mb-3"></i>
|
|
<p className="font-medium text-gray-600">Tidak ada data</p>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
Belum ada data karung IOT untuk siklus ini. Sinkronkan lewat tombol Sync hari ini.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const renderBox = (
|
|
title: string,
|
|
emoji: string,
|
|
headerColor: string,
|
|
manualGetter: (row: ManualInput) => number,
|
|
iotGetter: (row: Karung) => number,
|
|
totalGetter: (row: Karung) => number,
|
|
ref: React.RefObject<HTMLDivElement | null>
|
|
) => (
|
|
<div className="mb-6" ref={ref} style={{ scrollMarginTop: '100px' }}>
|
|
{/* Header */}
|
|
<div
|
|
className="px-6 py-3 rounded-t-xl font-bold text-black text-lg flex items-center gap-2"
|
|
style={{ backgroundColor: headerColor }}
|
|
>
|
|
<span>{emoji}</span>
|
|
<span>{title}</span>
|
|
</div>
|
|
|
|
{/* Table container with scroll */}
|
|
<div className="bg-white rounded-b-xl shadow-sm overflow-hidden">
|
|
<div className="overflow-x-auto" style={{ maxHeight: '600px', overflowY: 'auto' }}>
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0 z-10">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left font-semibold text-gray-700">Hari</th>
|
|
<th className="px-4 py-3 text-left font-semibold text-gray-700">Tanggal</th>
|
|
<th className="px-4 py-2 text-center font-semibold bg-blue-50 text-gray-700">
|
|
Manual
|
|
</th>
|
|
<th className="px-4 py-2 text-center font-semibold bg-gray-100 text-gray-700">
|
|
IOT
|
|
</th>
|
|
<th className="px-4 py-2 text-center font-semibold bg-purple-50 text-gray-700">
|
|
Akurasi
|
|
</th>
|
|
<th className="px-4 py-3 text-center font-semibold text-gray-700">Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{sortedData.map((summary) => {
|
|
const manualValue = summary.manual ? manualGetter(summary.manual) : 0;
|
|
const iotValue = summary.iot ? iotGetter(summary.iot) : 0;
|
|
const total = summary.iot ? totalGetter(summary.iot) : null;
|
|
return (
|
|
<tr key={summary.day} className="hover:bg-gray-50 transition-colors">
|
|
<td className="px-4 py-3">
|
|
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-800 font-bold text-xs">
|
|
{summary.day}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-700">{summary.dateFormatted}</td>
|
|
<td className="px-4 py-3 text-center font-semibold text-blue-700">
|
|
{formatNumber(manualValue)}
|
|
</td>
|
|
<td className="px-4 py-3 text-center text-gray-700">
|
|
{formatNumber(iotValue)}
|
|
</td>
|
|
<td
|
|
className={`px-4 py-3 text-center text-xs font-medium ${accuracyClass(
|
|
manualValue,
|
|
iotValue
|
|
)}`}
|
|
>
|
|
{accuracyOf(manualValue, iotValue)}
|
|
</td>
|
|
<td className="px-4 py-3 text-center text-gray-700">
|
|
{total == null ? '-' : formatNumber(total)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="bg-gray-50 p-6 rounded-xl">
|
|
{/* Header */}
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h2 className="text-2xl font-bold text-gray-800">Rincian Harian Karung IOT</h2>
|
|
<button
|
|
onClick={() => setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'))}
|
|
className="flex items-center gap-2 px-4 py-2 bg-white hover:bg-gray-100 rounded-lg text-sm font-medium shadow-sm transition-colors"
|
|
>
|
|
<i
|
|
className={`fa-solid ${
|
|
sortOrder === 'asc' ? 'fa-arrow-up-short-wide' : 'fa-arrow-down-short-wide'
|
|
}`}
|
|
></i>
|
|
<span>{sortOrder === 'asc' ? 'Terlama' : 'Terbaru'}</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Sticky Quick Navigation Bar (Excel-style freeze) */}
|
|
<div className="sticky top-0 z-20 -mx-6 mb-6 bg-white border-y border-gray-200 shadow-md">
|
|
<div className="px-6 py-4">
|
|
<div className="flex gap-2 justify-center">
|
|
<button
|
|
onClick={() => scrollToSection(masukRef)}
|
|
className="flex-1 max-w-xs flex items-center justify-center gap-2 px-6 py-3 bg-green-50 hover:bg-green-100 border-2 border-green-300 text-green-900 rounded-lg font-semibold shadow-sm transition-all hover:shadow-md active:scale-95"
|
|
>
|
|
<span className="text-xl">🟢</span>
|
|
<span>Karung Masuk</span>
|
|
<i className="fa-solid fa-chevron-down text-xs ml-1"></i>
|
|
</button>
|
|
<button
|
|
onClick={() => scrollToSection(tuangRef)}
|
|
className="flex-1 max-w-xs flex items-center justify-center gap-2 px-6 py-3 bg-orange-50 hover:bg-orange-100 border-2 border-orange-300 text-orange-900 rounded-lg font-semibold shadow-sm transition-all hover:shadow-md active:scale-95"
|
|
>
|
|
<span className="text-xl">🟠</span>
|
|
<span>Karung Tuang</span>
|
|
<i className="fa-solid fa-chevron-down text-xs ml-1"></i>
|
|
</button>
|
|
<button
|
|
onClick={() => scrollToSection(keluarRef)}
|
|
className="flex-1 max-w-xs flex items-center justify-center gap-2 px-6 py-3 bg-red-50 hover:bg-red-100 border-2 border-red-300 text-red-900 rounded-lg font-semibold shadow-sm transition-all hover:shadow-md active:scale-95"
|
|
>
|
|
<span className="text-xl">🔴</span>
|
|
<span>Karung Keluar</span>
|
|
<i className="fa-solid fa-chevron-down text-xs ml-1"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Three separate boxes */}
|
|
{renderBox(
|
|
'KARUNG MASUK',
|
|
'🟢',
|
|
'#86efac',
|
|
(row) => row.feed_in_manual,
|
|
(row) => row.in_today,
|
|
(row) => row.in_total,
|
|
masukRef
|
|
)}
|
|
{renderBox(
|
|
'KARUNG TUANG',
|
|
'🟠',
|
|
'#fdba74',
|
|
(row) => row.feed_use_manual,
|
|
(row) => row.feed_use_today,
|
|
(row) => row.feed_use_total,
|
|
tuangRef
|
|
)}
|
|
{renderBox(
|
|
'KARUNG KELUAR',
|
|
'🔴',
|
|
'#fca5a5',
|
|
(row) => row.feed_out_manual,
|
|
(row) => row.out_today,
|
|
(row) => row.out_total,
|
|
keluarRef
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FeedDailySummaryTable;
|