Files
dashboard-cpsp/components/dashboard/ReportPreviewOverlay.tsx
T

566 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, errorMessage } from '../../services/apiClient.ts';
import { useFarm } from '../../context/FarmContext.tsx';
import { buildInMemoryReport } from '../../utils/buildInMemoryReport.ts';
import { downloadReportExcel } from '../../utils/reportExcel.ts';
import { exportElementToPdf } from '../../utils/pdfExport.ts';
import {
countingTotals,
feedInTotals,
feedOutTotals,
feedUseTotals,
} from '../../utils/reportTotals.ts';
import {
cycleDayForDate,
formatDateId,
formatDateRange,
formatNumber,
formatRatio,
} from '../../utils/format.ts';
import type {
DashboardReportFcrRow,
DashboardReportPayload,
DashboardReportType,
} from '../../types/dashboardReport.ts';
import { Banner } from '../ui/Feedback.tsx';
type Props = {
open: boolean;
reportType: DashboardReportType;
onClose: () => void;
};
const cell = (value: number | string | null | undefined, suffix = ''): string => {
if (value === null || value === undefined || value === '') return 'N/A';
if (typeof value === 'number' && !Number.isFinite(value)) return 'N/A';
return `${value}${suffix}`;
};
const dashCell = (value: number | null | undefined): string =>
value == null || !Number.isFinite(value) ? '-' : String(value);
const accuracyCell = (value: number | null | undefined): string =>
value == null || !Number.isFinite(value) ? 'N/A' : `${value.toFixed(1)}%`;
const ratioCell = (value: number | null | undefined): string =>
value == null || !Number.isFinite(value) ? 'N/A' : formatRatio(Number(value), 3);
const qtyCell = (value: number | null | undefined, maxDecimals = 0, suffix = ''): string => {
if (value == null || !Number.isFinite(value)) return 'N/A';
return `${formatNumber(Number(value), maxDecimals, 0, true)}${suffix}`;
};
const harvestKgCell = (grams: number | null | undefined): string => {
if (grams == null || !Number.isFinite(grams)) return 'N/A';
return `${formatNumber(Number(grams) / 1000, 1)} kg`;
};
const latestValidRatio = (
rows: Array<DashboardReportFcrRow | { iotEef: number | null }>,
key: 'iotFcr' | 'iotEef'
): number | null => {
for (let i = rows.length - 1; i >= 0; i -= 1) {
const value = (rows[i] as Record<string, number | null>)[key];
if (value != null && Number.isFinite(value)) return value;
}
return null;
};
const ReportPreviewOverlay: React.FC<Props> = ({ open, reportType, onClose }) => {
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
const isEndCycle = reportType === 'end_cycle';
const [draftDay, setDraftDay] = useState(0);
const [payload, setPayload] = useState<DashboardReportPayload | null>(null);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const printRef = useRef<HTMLDivElement>(null);
const latestDay = selectedCycle ? Math.max(0, selectedCycle.current_day) : 0;
const endCycleDay = selectedCycle
? Math.min(
selectedCycle.total_days,
cycleDayForDate(selectedCycle.start_date, selectedCycle.end_date)
)
: 0;
const effectiveUpToDay = isEndCycle ? endCycleDay : draftDay;
const dayOptions = useMemo(
() => Array.from({ length: latestDay + 1 }, (_, day) => day),
[latestDay]
);
useEffect(() => {
if (!open) return;
setPayload(null);
setError(null);
setGenerating(false);
setDraftDay(isEndCycle ? endCycleDay : latestDay);
}, [open, isEndCycle, latestDay, endCycleDay]);
const generate = useCallback(async () => {
if (!selectedCycle || !selectedKandang) return;
setGenerating(true);
setError(null);
try {
const cycleId = selectedCycle.id;
const [countings, manuals, weights, karungs, kpis] = await Promise.all([
api.countings.list({ cycle_id: cycleId }),
api.manualInputs.list({ cycle_id: cycleId }),
api.weights.list({ cycle_id: cycleId }),
api.karungs.list({ cycle_id: cycleId }),
api.kpis.list({ cycle_id: cycleId }),
]);
const next = buildInMemoryReport({
cycle: selectedCycle,
kandangId: selectedKandang.id,
kandangName: selectedKandang.kandang_name,
area: selectedSite?.site_name ?? '',
reportType,
countings,
manuals,
weights,
karungs,
kpis,
upToDay: effectiveUpToDay,
});
setPayload(next);
} catch (err) {
setError(errorMessage(err));
} finally {
setGenerating(false);
}
}, [reportType, selectedCycle, selectedKandang, selectedSite, effectiveUpToDay]);
const downloadPdf = useCallback(async () => {
if (!printRef.current || !payload) return;
const period = isEndCycle ? 'Akhir-Siklus' : `Harian-Hari-${payload.upToDay}`;
const filename = `Laporan-${period}-${payload.area}-${payload.kandangName}-${payload.cycleStart}_sd_${payload.cycleEnd}.pdf`;
await exportElementToPdf(printRef.current, filename);
}, [payload, isEndCycle]);
if (!open) return null;
const title = isEndCycle ? 'Laporan Akhir Siklus' : 'Laporan Harian';
const cycleLabel = selectedCycle
? `Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`
: '';
const countingTotal = countingTotals(payload?.counting || []);
const feedInTotal = feedInTotals(payload?.feedIn || []);
const feedUseTotal = feedUseTotals(payload?.feedUse || []);
const feedOutTotal = feedOutTotals(payload?.feedOut || []);
const endDateLabel = selectedCycle ? formatDateId(selectedCycle.start_date) : '';
const dataDates =
selectedCycle && payload
? `${endDateLabel} – ${formatDateId(payload.counting[payload.counting.length - 1]?.date ?? selectedCycle.start_date)}`
: '';
const cycleRange = selectedCycle
? formatDateRange(selectedCycle.start_date, selectedCycle.end_date)
: '';
const printedDate = new Date().toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
return (
<div className="fixed inset-0 z-50 bg-slate-900/60" role="dialog" aria-modal="true">
<div className="flex h-full w-full flex-col bg-slate-50">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-4 py-3 sm:px-6">
<div>
<h2 id="report-preview-title" className="text-lg font-semibold text-slate-900">
{title}
</h2>
<p className="text-xs text-slate-500">
{selectedKandang?.kandang_name ?? 'Pilih kandang'} · {cycleLabel} · Hari ke-0 s/d ke-
{effectiveUpToDay}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{!isEndCycle && (
<>
<label className="sr-only" htmlFor="report-day">
Hari siklus
</label>
<select
id="report-day"
aria-label="Hari siklus"
value={draftDay}
onChange={(event) => setDraftDay(Number(event.target.value))}
className="rounded-lg border border-slate-200 bg-white px-2 py-1.5 text-xs font-semibold text-slate-700 shadow-sm"
>
{dayOptions.map((day) => (
<option key={day} value={day}>
Hari {day}
</option>
))}
</select>
</>
)}
<button
type="button"
onClick={() => void generate()}
disabled={!selectedCycle || !selectedKandang || generating}
className="rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{generating ? 'Memproses…' : payload ? 'Perbarui' : 'Generate'}
</button>
<button
type="button"
onClick={() => payload && void downloadPdf()}
disabled={!payload}
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
>
PDF
</button>
<button
type="button"
onClick={() => payload && void downloadReportExcel(payload)}
disabled={!payload}
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Excel
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50"
aria-label="Tutup"
>
<i className="fa-solid fa-xmark" />
</button>
</div>
</header>
<div className="flex-1 overflow-auto px-4 py-4 sm:px-6">
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
</div>
)}
<div
id="report-snapshot-print"
ref={printRef}
className="rounded-xl bg-white p-4 shadow-sm"
>
{!payload ? (
<p className="py-8 text-center text-sm text-slate-500">
Tekan <strong>Generate</strong> untuk membuat laporan dari data siklus saat ini.
</p>
) : (
<>
<div className="mb-6 border-b-2 border-purple-700 pb-4">
<div style={{ fontSize: 17, fontWeight: 700, color: '#111827' }}>{title}</div>
<div style={{ fontSize: 13, color: '#374151', marginTop: 5 }}>
<strong>Lokasi:</strong> {payload.area} — {payload.kandangName}
</div>
<div style={{ fontSize: 13, color: '#374151', marginTop: 2 }}>
<strong>Periode:</strong> Hari ke-0 s/d ke-{payload.upToDay} {dataDates}
</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>
<strong>Siklus:</strong> {cycleRange}
</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>
Dicetak: {printedDate}
</div>
{isEndCycle && (
<div style={{ fontSize: 22, color: '#111827', marginTop: 12, fontWeight: 700 }}>
FCR akhir: {ratioCell(latestValidRatio(payload.fcr, 'iotFcr'))} · EEF akhir:{' '}
{ratioCell(latestValidRatio(payload.eef, 'iotEef'))}
</div>
)}
</div>
<ReportTable title="Hitung Ayam harian">
<thead>
<tr>
<Th>Tanggal</Th>
<Th>Umur (hari)</Th>
<Th>Stok awal</Th>
<Th>Mortalitas</Th>
<Th>Panen</Th>
<Th>Berat panen</Th>
<Th>Stok akhir</Th>
<Th>IoT</Th>
<Th>Akurasi %</Th>
</tr>
</thead>
<tbody>
{payload.counting.map((row) => (
<tr key={`c-${row.day}`} className="border-t border-slate-100" data-pdf-block>
<Td>{formatDateId(row.date)}</Td>
<Td>{row.day}</Td>
<Td>{cell(row.countBefore)}</Td>
<Td>{cell(row.mortality)}</Td>
<Td>{cell(row.harvest)}</Td>
<Td>{cell(row.harvestWeight)}</Td>
<Td>{cell(row.countAfter)}</Td>
<Td>{cell(row.iotCount)}</Td>
<Td>{accuracyCell(row.accuracy)}</Td>
</tr>
))}
</tbody>
{countingTotal && (
<tfoot>
<TotalRow>
<Td>{countingTotal.label}</Td>
<Td>—</Td>
<Td>{dashCell(countingTotal.countBefore)}</Td>
<Td>{cell(countingTotal.mortality)}</Td>
<Td>{cell(countingTotal.harvest)}</Td>
<Td>{cell(countingTotal.harvestWeight)}</Td>
<Td>{dashCell(countingTotal.countAfter)}</Td>
<Td>{dashCell(countingTotal.iotCount)}</Td>
<Td>
{countingTotal.accuracy == null
? '-'
: accuracyCell(countingTotal.accuracy)}
</Td>
</TotalRow>
</tfoot>
)}
</ReportTable>
<ReportTable title="Berat Ayam harian" divided>
<thead>
<tr>
<Th>Tanggal</Th>
<Th>Umur (hari)</Th>
<Th>Rata-rata manual</Th>
<Th>Rata-rata IoT</Th>
<Th>Keseragaman</Th>
<Th>Selisih</Th>
<Th>Akurasi %</Th>
</tr>
</thead>
<tbody>
{payload.weight.map((row) => (
<tr key={`w-${row.day}`} className="border-t border-slate-100" data-pdf-block>
<Td>{formatDateId(row.date)}</Td>
<Td>{row.day}</Td>
<Td>{cell(row.avgManual)}</Td>
<Td>{cell(row.avgIot)}</Td>
<Td>{accuracyCell(row.uniformity)}</Td>
<Td>{cell(row.difference)}</Td>
<Td>{accuracyCell(row.accuracy)}</Td>
</tr>
))}
</tbody>
</ReportTable>
<ReportTable title="Karung masuk" divided>
<thead>
<tr>
<Th>Tanggal</Th>
<Th>Manual masuk</Th>
<Th>IoT masuk</Th>
<Th>Akurasi %</Th>
</tr>
</thead>
<tbody>
{payload.feedIn.map((row) => (
<tr
key={`in-${row.date}`}
className="border-t border-slate-100"
data-pdf-block
>
<Td>{formatDateId(row.date)}</Td>
<Td>{cell(row.manualIn)}</Td>
<Td>{cell(row.iotIn)}</Td>
<Td>{accuracyCell(row.accuracy)}</Td>
</tr>
))}
</tbody>
{feedInTotal && (
<tfoot>
<TotalRow>
<Td>{feedInTotal.label}</Td>
<Td>{cell(feedInTotal.manualIn)}</Td>
<Td>{cell(feedInTotal.iotIn)}</Td>
<Td>{accuracyCell(feedInTotal.accuracy)}</Td>
</TotalRow>
</tfoot>
)}
</ReportTable>
<ReportTable title="Karung dituang" divided>
<thead>
<tr>
<Th>Tanggal</Th>
<Th>Umur (hari)</Th>
<Th>Manual dituang</Th>
<Th>IoT dituang</Th>
<Th>Akurasi %</Th>
</tr>
</thead>
<tbody>
{payload.feedUse.map((row) => (
<tr
key={`use-${row.day}-${row.date}`}
className="border-t border-slate-100"
data-pdf-block
>
<Td>{formatDateId(row.date)}</Td>
<Td>{row.day}</Td>
<Td>{cell(row.manualUse)}</Td>
<Td>{cell(row.iotUse)}</Td>
<Td>{accuracyCell(row.accuracy)}</Td>
</tr>
))}
</tbody>
{feedUseTotal && (
<tfoot>
<TotalRow>
<Td>{feedUseTotal.label}</Td>
<Td>—</Td>
<Td>{cell(feedUseTotal.manualUse)}</Td>
<Td>{cell(feedUseTotal.iotUse)}</Td>
<Td>{accuracyCell(feedUseTotal.accuracy)}</Td>
</TotalRow>
</tfoot>
)}
</ReportTable>
<ReportTable title="Karung keluar" divided>
<thead>
<tr>
<Th>Tanggal</Th>
<Th>Manual keluar</Th>
<Th>IoT keluar</Th>
<Th>Akurasi %</Th>
</tr>
</thead>
<tbody>
{payload.feedOut.map((row) => (
<tr
key={`out-${row.date}`}
className="border-t border-slate-100"
data-pdf-block
>
<Td>{formatDateId(row.date)}</Td>
<Td>{cell(row.manualOut)}</Td>
<Td>{cell(row.iotOut)}</Td>
<Td>{accuracyCell(row.accuracy)}</Td>
</tr>
))}
</tbody>
{feedOutTotal && (
<tfoot>
<TotalRow>
<Td>{feedOutTotal.label}</Td>
<Td>{cell(feedOutTotal.manualOut)}</Td>
<Td>{cell(feedOutTotal.iotOut)}</Td>
<Td>{accuracyCell(feedOutTotal.accuracy)}</Td>
</TotalRow>
</tfoot>
)}
</ReportTable>
{isEndCycle && (
<>
<ReportTable title="Data Pendukung FCR" divided>
<thead>
<tr>
<Th>Umur</Th>
<Th>Total Pakan</Th>
<Th>Bobot IoT</Th>
<Th>Ayam Hidup</Th>
<Th>Total Bobot Panen</Th>
<Th>FCR</Th>
</tr>
</thead>
<tbody>
{payload.fcr.map((row) => (
<tr
key={`fcr-${row.day}`}
className="border-t border-slate-100"
data-pdf-block
>
<Td>Hari {row.day}</Td>
<Td>{qtyCell(row.feedTotal, 0, ' karung')}</Td>
<Td>{qtyCell(row.iotWeight, 2, row.iotWeight != null ? ' g' : '')}</Td>
<Td>{qtyCell(row.chickenLife, 0, ' ekor')}</Td>
<Td>{harvestKgCell(row.harvestWeightTotal)}</Td>
<Td>{ratioCell(row.iotFcr)}</Td>
</tr>
))}
</tbody>
</ReportTable>
<ReportTable title="Data Pendukung EEF" divided>
<thead>
<tr>
<Th>Umur</Th>
<Th>Persentase Ayam Hidup</Th>
<Th>BW Rata-rata Panen</Th>
<Th>Umur Rata-rata Panen</Th>
<Th>FCR</Th>
<Th>EEF</Th>
</tr>
</thead>
<tbody>
{payload.eef.map((row) => (
<tr
key={`eef-${row.day}`}
className="border-t border-slate-100"
data-pdf-block
>
<Td>Hari {row.day}</Td>
<Td>{qtyCell(row.chickenLifePercentage, 3, '%')}</Td>
<Td>
{qtyCell(
row.iotAverageHarvestBw,
2,
row.iotAverageHarvestBw != null ? ' g' : ''
)}
</Td>
<Td>{qtyCell(row.iotAverageDayWeight, 2)}</Td>
<Td>{ratioCell(row.iotFcr)}</Td>
<Td>{ratioCell(row.iotEef)}</Td>
</tr>
))}
</tbody>
</ReportTable>
</>
)}
</>
)}
</div>
</div>
</div>
</div>
);
};
const ReportTable: React.FC<{
title: string;
children: React.ReactNode;
divided?: boolean;
}> = ({ title, children, divided = false }) => (
<section
data-pdf-block="true"
className={divided ? 'mt-10 border-t-2 border-slate-400 pt-10' : undefined}
>
<h3 className="mb-2 text-sm font-semibold text-slate-800">{title}</h3>
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="min-w-full text-left text-xs text-slate-700">{children}</table>
</div>
</section>
);
const Th: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<th className="bg-slate-50 px-3 py-2 font-semibold text-slate-600">{children}</th>
);
const Td: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<td className="px-3 py-2 tabular-nums">{children}</td>
);
const TotalRow: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<tr className="border-t-2 border-slate-200 bg-slate-50 font-semibold" data-pdf-block>
{children}
</tr>
);
export default ReportPreviewOverlay;