first commit
This commit is contained in:
commit
b54624be96
226 files changed
+108840
No files matched your search
@@ -0,0 +1,265 @@
|
||||
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;
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { Karung } from '../../types/api.ts';
|
||||
import { formatNumber } from '../../utils/format.ts';
|
||||
|
||||
interface FeedSaldoCardProps {
|
||||
latest: Karung | null;
|
||||
feedInitialBalance: number;
|
||||
cycleStartDate: string;
|
||||
displayDate: string;
|
||||
}
|
||||
|
||||
const FeedSaldoCard: React.FC<FeedSaldoCardProps> = ({
|
||||
latest,
|
||||
feedInitialBalance,
|
||||
cycleStartDate,
|
||||
displayDate,
|
||||
}) => {
|
||||
const saldoData = useMemo(() => {
|
||||
const inbound = (latest?.in_total ?? 0) + feedInitialBalance;
|
||||
const feedUse = latest?.feed_use_total ?? 0;
|
||||
const keluar = latest?.out_total ?? 0;
|
||||
const saldo = inbound - feedUse - keluar;
|
||||
return { inbound, feedUse, keluar, saldo };
|
||||
}, [latest, feedInitialBalance]);
|
||||
|
||||
const formatDateRange = useMemo(() => {
|
||||
const formatDate = (iso: string) =>
|
||||
new Intl.DateTimeFormat('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(new Date(`${iso}T00:00:00`));
|
||||
return `${formatDate(cycleStartDate)} - ${formatDate(displayDate)}`;
|
||||
}, [cycleStartDate, displayDate]);
|
||||
|
||||
const getSaldoStatusColor = (saldo: number) => {
|
||||
if (saldo > 0) return 'text-green-600';
|
||||
if (saldo < 0) return 'text-red-600';
|
||||
return 'text-gray-600';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-6 rounded-xl shadow-sm border-2 border-blue-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-blue-500 text-white p-3 rounded-lg">
|
||||
<i className="fa-solid fa-scale-balanced text-2xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">Saldo Karung IOT</h2>
|
||||
<p className="text-sm text-gray-600">{formatDateRange}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saldo Display */}
|
||||
<div className="bg-white rounded-lg p-6 border border-blue-200">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Karung Masuk */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<i className="fa-solid fa-arrow-down text-green-600"></i>
|
||||
<p className="text-sm font-semibold text-gray-700">Karung Masuk</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-green-600">{formatNumber(saldoData.inbound)}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">karung</p>
|
||||
</div>
|
||||
|
||||
{/* Karung Dituang (feed use) */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<i className="fa-solid fa-arrow-up text-orange-600"></i>
|
||||
<p className="text-sm font-semibold text-gray-700">Karung Dituang</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-orange-600">{formatNumber(saldoData.feedUse)}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">karung</p>
|
||||
</div>
|
||||
|
||||
{/* Karung Keluar */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<i className="fa-solid fa-arrow-right text-red-600"></i>
|
||||
<p className="text-sm font-semibold text-gray-700">Karung Keluar</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-red-600">{formatNumber(saldoData.keluar)}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">karung</p>
|
||||
</div>
|
||||
|
||||
{/* Saldo */}
|
||||
<div className="text-center lg:border-l-2 lg:border-blue-200 lg:pl-6">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<i className="fa-solid fa-scale-balanced text-blue-600"></i>
|
||||
<p className="text-sm font-semibold text-gray-700">Saldo</p>
|
||||
</div>
|
||||
<p className={`text-4xl font-bold ${getSaldoStatusColor(saldoData.saldo)}`}>
|
||||
{formatNumber(saldoData.saldo)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">karung</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-4 text-center">
|
||||
Saldo = karung masuk − karung dituang − karung keluar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Warning for negative saldo */}
|
||||
{saldoData.saldo < 0 && (
|
||||
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-3 flex items-start gap-2">
|
||||
<i className="fa-solid fa-triangle-exclamation text-yellow-600 mt-0.5"></i>
|
||||
<p className="text-xs text-yellow-800">
|
||||
<strong>Peringatan:</strong> Saldo negatif menunjukkan karung dituang lebih banyak dari
|
||||
karung masuk. Periksa kembali data sinkronisasi karung-web-admin.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedSaldoCard;
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { Karung, ManualInput } from '../../types/api.ts';
|
||||
import { formatNumber } from '../../utils/format.ts';
|
||||
|
||||
const StatItem: React.FC<{
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: number | null;
|
||||
colorOverride?: string;
|
||||
}> = ({ icon, label, value, colorOverride }) => (
|
||||
<div className="border border-gray-200 rounded-lg p-3 hover:border-gray-300 transition-colors">
|
||||
<div className="flex items-start gap-2 mb-3 min-w-0">
|
||||
<div className="mt-0.5 shrink-0 text-gray-500">{icon}</div>
|
||||
<p className="min-w-0 text-[11px] sm:text-xs font-medium leading-tight text-gray-700 break-words">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-gray-50/70 p-3">
|
||||
<p
|
||||
className="text-xl sm:text-2xl font-bold leading-none"
|
||||
style={{ color: colorOverride ?? '#374151' }}
|
||||
>
|
||||
{value == null ? formatNumber(0) : formatNumber(value)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">karung</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface FeedStatsProps {
|
||||
latest: Karung | null;
|
||||
latestManual: ManualInput | null;
|
||||
cycleStartDate: string;
|
||||
displayDate: string;
|
||||
}
|
||||
|
||||
const FeedStats: React.FC<FeedStatsProps> = ({
|
||||
latest,
|
||||
latestManual,
|
||||
cycleStartDate,
|
||||
displayDate,
|
||||
}) => {
|
||||
const formatDisplayDate = (iso: string) =>
|
||||
new Intl.DateTimeFormat('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(new Date(`${iso}T00:00:00`));
|
||||
|
||||
const todayLabel = useMemo(() => formatDisplayDate(displayDate), [displayDate]);
|
||||
|
||||
const totalCycleLabel = useMemo(() => {
|
||||
const start = new Intl.DateTimeFormat('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
}).format(new Date(`${cycleStartDate}T00:00:00`));
|
||||
const end = new Intl.DateTimeFormat('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
}).format(new Date(`${displayDate}T00:00:00`));
|
||||
return `Total (${start} - ${end})`;
|
||||
}, [cycleStartDate, displayDate]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'masuk',
|
||||
title: 'Karung Masuk',
|
||||
color: '#2563eb',
|
||||
icon: <i className="fa-solid fa-arrow-down text-blue-600"></i>,
|
||||
iotToday: latest?.in_today ?? 0,
|
||||
iotTotal: latest?.in_total ?? 0,
|
||||
manualToday: latestManual?.feed_in_manual ?? 0,
|
||||
manualTotal: latestManual?.feed_in_manual_total ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'dituang',
|
||||
title: 'Karung Dituang',
|
||||
color: '#ea580c',
|
||||
icon: <i className="fa-solid fa-arrow-up text-orange-600"></i>,
|
||||
iotToday: latest?.feed_use_today ?? 0,
|
||||
iotTotal: latest?.feed_use_total ?? 0,
|
||||
manualToday: latestManual?.feed_use_manual ?? 0,
|
||||
manualTotal: latestManual?.feed_use_manual_total ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'keluar',
|
||||
title: 'Karung Keluar',
|
||||
color: '#dc2626',
|
||||
icon: <i className="fa-solid fa-arrow-right text-red-600"></i>,
|
||||
iotToday: latest?.out_today ?? 0,
|
||||
iotTotal: latest?.out_total ?? 0,
|
||||
manualToday: latestManual?.feed_out_manual ?? 0,
|
||||
manualTotal: latestManual?.feed_out_manual_total ?? 0,
|
||||
},
|
||||
],
|
||||
[latest, latestManual]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 sm:p-6 rounded-xl shadow-sm">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
|
||||
{columns.map((col) => (
|
||||
<div key={col.key}>
|
||||
<h3 className="font-semibold text-sm sm:text-base mb-2" style={{ color: col.color }}>
|
||||
{col.title}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-3">
|
||||
<StatItem
|
||||
icon={<i className="fa-solid fa-microchip text-purple-600"></i>}
|
||||
label={`IOT · ${todayLabel}`}
|
||||
value={col.iotToday}
|
||||
colorOverride={col.color}
|
||||
/>
|
||||
<StatItem
|
||||
icon={<i className="fa-solid fa-microchip text-purple-600"></i>}
|
||||
label={`IOT · ${totalCycleLabel}`}
|
||||
value={col.iotTotal}
|
||||
colorOverride={col.color}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<StatItem
|
||||
icon={<i className="fa-solid fa-user-pen text-gray-500"></i>}
|
||||
label={`Manual · ${todayLabel}`}
|
||||
value={col.manualToday}
|
||||
colorOverride="#6b7280"
|
||||
/>
|
||||
<StatItem
|
||||
icon={<i className="fa-solid fa-user-pen text-gray-500"></i>}
|
||||
label={`Manual · ${totalCycleLabel}`}
|
||||
value={col.manualTotal}
|
||||
colorOverride="#6b7280"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedStats;
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import type { Karung } from '../../types/api.ts';
|
||||
|
||||
interface FeedTrendChartProps {
|
||||
rows: Karung[];
|
||||
cycleStartDate: string;
|
||||
currentDay: number;
|
||||
}
|
||||
|
||||
const FeedTrendChart: React.FC<FeedTrendChartProps> = ({ rows, cycleStartDate, currentDay }) => {
|
||||
const chartData = useMemo(() => {
|
||||
const cycleStart = new Date(`${cycleStartDate}T00:00:00`);
|
||||
|
||||
const dailyData: { label: string; masuk: number; dituang: number; keluar: number }[] = [];
|
||||
for (let d = 0; d <= currentDay; d++) {
|
||||
const date = new Date(cycleStart);
|
||||
date.setDate(cycleStart.getDate() + d);
|
||||
dailyData.push({
|
||||
label: date.toLocaleDateString('id-ID', { day: '2-digit', month: 'short' }),
|
||||
masuk: 0,
|
||||
dituang: 0,
|
||||
keluar: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const byKey = new Map<string, { masuk: number; dituang: number; keluar: number }>();
|
||||
for (const row of rows) {
|
||||
const bucket = byKey.get(row.date) ?? { masuk: 0, dituang: 0, keluar: 0 };
|
||||
bucket.masuk += row.in_today;
|
||||
bucket.dituang += row.feed_use_today ?? 0;
|
||||
bucket.keluar += row.out_today ?? 0;
|
||||
byKey.set(row.date, bucket);
|
||||
}
|
||||
|
||||
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')}`;
|
||||
const bucket = byKey.get(key);
|
||||
const entry = dailyData[d];
|
||||
if (bucket && entry) {
|
||||
entry.masuk = bucket.masuk;
|
||||
entry.dituang = bucket.dituang;
|
||||
entry.keluar = bucket.keluar;
|
||||
}
|
||||
}
|
||||
|
||||
return dailyData;
|
||||
}, [rows, cycleStartDate, currentDay]);
|
||||
|
||||
const seriesLabel = (name: string) => {
|
||||
if (name === 'masuk') return 'Karung Masuk';
|
||||
if (name === 'dituang') return 'Karung Dituang';
|
||||
return 'Karung Keluar';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Tren Harian Karung IOT</h2>
|
||||
<div className="h-60">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 5, right: 20, left: -15, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
fontSize={11}
|
||||
tick={{ fill: '#6b7280' }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={70}
|
||||
/>
|
||||
<YAxis fontSize={12} tick={{ fill: '#6b7280' }} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
formatter={(value: unknown, name: unknown) => [
|
||||
`${Number(value ?? 0)} karung`,
|
||||
seriesLabel(String(name)),
|
||||
]}
|
||||
labelFormatter={(label) => `Tanggal: ${label}`}
|
||||
/>
|
||||
<Legend formatter={(value) => seriesLabel(String(value))} />
|
||||
<Bar dataKey="masuk" name="masuk" fill="#2563eb" barSize={8} />
|
||||
<Bar dataKey="dituang" name="dituang" fill="#ea580c" barSize={8} />
|
||||
<Bar dataKey="keluar" name="keluar" fill="#dc2626" barSize={8} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedTrendChart;
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { api, errorMessage } from '../../services/apiClient.ts';
|
||||
import type { Cycle, InitialBalanceResult } from '../../types/api.ts';
|
||||
import { formatNumber } from '../../utils/format.ts';
|
||||
import InitialBalanceComparison from './InitialBalanceComparison.tsx';
|
||||
|
||||
interface InitialBalanceCardProps {
|
||||
cycle: Cycle;
|
||||
}
|
||||
|
||||
const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle }) => {
|
||||
const savedBalanceDate = cycle.feed_initial_balance_date ?? cycle.start_date;
|
||||
const [balanceDate, setBalanceDate] = useState(savedBalanceDate);
|
||||
const [manualEntry, setManualEntry] = useState<string>(
|
||||
cycle.feed_initial_balance > 0 ? String(cycle.feed_initial_balance) : ''
|
||||
);
|
||||
const [comparing, setComparing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [comparison, setComparison] = useState<InitialBalanceResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBalanceDate(cycle.feed_initial_balance_date ?? cycle.start_date);
|
||||
setManualEntry(cycle.feed_initial_balance > 0 ? String(cycle.feed_initial_balance) : '');
|
||||
setComparison(null);
|
||||
setError(null);
|
||||
}, [cycle.id, cycle.feed_initial_balance, cycle.feed_initial_balance_date, cycle.start_date]);
|
||||
|
||||
const manualValue = manualEntry === '' ? null : Number(manualEntry);
|
||||
|
||||
const loadComparison = useCallback(async () => {
|
||||
if (balanceDate === '' || manualValue == null || Number.isNaN(manualValue) || manualValue < 0) {
|
||||
setComparison(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setComparing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const compared = await api.cycles.initialBalanceCompare(cycle.id, {
|
||||
date: balanceDate,
|
||||
manual: manualValue,
|
||||
});
|
||||
setComparison(compared);
|
||||
} catch (err) {
|
||||
setComparison(null);
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setComparing(false);
|
||||
}
|
||||
}, [balanceDate, cycle.id, manualValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (manualValue == null || manualValue < 0) {
|
||||
setComparison(null);
|
||||
return;
|
||||
}
|
||||
void loadComparison();
|
||||
}, [loadComparison, manualValue]);
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-6 rounded-xl shadow-sm border-2 border-blue-200">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="bg-blue-500 text-white p-3 rounded-lg">
|
||||
<i className="fa-solid fa-boxes-packing text-2xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">Saldo Awal</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Masukkan saldo awal karung pakan pada tanggal tertentu, lalu bandingkan dengan deteksi
|
||||
IoT.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-6 border border-blue-200">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="initial-balance-date"
|
||||
className="text-sm font-semibold text-gray-700 block mb-1"
|
||||
>
|
||||
Tanggal
|
||||
</label>
|
||||
<input
|
||||
id="initial-balance-date"
|
||||
type="date"
|
||||
value={balanceDate}
|
||||
onChange={(event) => setBalanceDate(event.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="initial-balance-manual"
|
||||
className="text-sm font-semibold text-gray-700 block mb-1"
|
||||
>
|
||||
Feed In Manual (karung)
|
||||
</label>
|
||||
<input
|
||||
id="initial-balance-manual"
|
||||
type="number"
|
||||
min={0}
|
||||
value={manualEntry}
|
||||
onChange={(event) => setManualEntry(event.target.value)}
|
||||
placeholder="Contoh: 120"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 text-sm font-semibold text-red-700 bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{cycle.feed_initial_balance > 0 && comparison == null && (
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Saldo awal tersimpan:{' '}
|
||||
<strong className="text-blue-700">
|
||||
{formatNumber(cycle.feed_initial_balance)} karung
|
||||
</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{comparison && <InitialBalanceComparison result={comparison} loading={comparing} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InitialBalanceCard;
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import type { InitialBalanceResult } from '../../types/api.ts';
|
||||
import { formatNumber } from '../../utils/format.ts';
|
||||
|
||||
interface InitialBalanceComparisonProps {
|
||||
result: InitialBalanceResult;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const InitialBalanceComparison: React.FC<InitialBalanceComparisonProps> = ({
|
||||
result,
|
||||
loading = false,
|
||||
}) => {
|
||||
const accuracy = result.accuracy;
|
||||
const accuracyLabel = accuracy == null ? 'N/A' : `${formatNumber(accuracy, 1)}%`;
|
||||
const accuracyColor =
|
||||
accuracy == null
|
||||
? 'text-gray-500'
|
||||
: accuracy >= 95
|
||||
? 'text-green-600'
|
||||
: accuracy >= 80
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600';
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50/50 p-4">
|
||||
<p className="text-sm font-semibold text-gray-800 mb-3">
|
||||
Perbandingan {result.date}
|
||||
{loading ? ' · memuat IoT...' : ''}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-center">
|
||||
<div className="rounded-lg bg-white border border-gray-200 p-3">
|
||||
<p className="text-xs font-semibold text-blue-700">Manual</p>
|
||||
<p className="text-2xl font-bold text-gray-800">{formatNumber(result.manual)} karung</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white border border-gray-200 p-3">
|
||||
<p className="text-xs font-semibold text-purple-700">IOT</p>
|
||||
<p className="text-2xl font-bold text-gray-800">
|
||||
{result.iot == null ? 'N/A' : `${formatNumber(result.iot)} karung`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white border border-gray-200 p-3">
|
||||
<p className="text-xs font-semibold text-gray-600">Akurasi</p>
|
||||
<p className={`text-2xl font-bold ${accuracyColor}`}>{accuracyLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.sync_error && (
|
||||
<p className="mt-3 text-xs text-yellow-700 bg-yellow-50 border border-yellow-200 rounded-lg p-2">
|
||||
Gagal menarik data IoT dari karung-web-admin: {result.sync_error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InitialBalanceComparison;
|
||||
Reference in new issue
Block a user