first commit
This commit is contained in:
commit
367533c02b
171 files changed
+101175
No files matched your search
@@ -0,0 +1,265 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api, errorMessage } from '../../services/apiClient.ts';
|
||||
import type { ManualInput, ManualInputWrite } from '../../types/api.ts';
|
||||
import { Banner } from '../ui/Feedback.tsx';
|
||||
import { formatNumber, todayIso } from '../../utils/format.ts';
|
||||
|
||||
const inputClass =
|
||||
'border border-gray-300 rounded-md px-3 py-2 text-sm w-full focus:ring-red-500 focus:border-red-500';
|
||||
|
||||
type Props = {
|
||||
cycleId: number;
|
||||
currentDay: number;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
date: string;
|
||||
age_manual: number;
|
||||
mortality_manual: number;
|
||||
feed_in_manual: number;
|
||||
feed_use_manual: number;
|
||||
harvest_manual: number;
|
||||
harvest_weight_manual: number;
|
||||
manual_weight: number;
|
||||
};
|
||||
|
||||
const emptyForm = (currentDay: number): FormState => ({
|
||||
date: todayIso(),
|
||||
age_manual: currentDay,
|
||||
mortality_manual: 0,
|
||||
feed_in_manual: 0,
|
||||
feed_use_manual: 0,
|
||||
harvest_manual: 0,
|
||||
harvest_weight_manual: 0,
|
||||
manual_weight: 0,
|
||||
});
|
||||
|
||||
function toForm(row: ManualInput): FormState {
|
||||
return {
|
||||
date: row.date,
|
||||
age_manual: row.age_manual,
|
||||
mortality_manual: row.mortality_manual,
|
||||
feed_in_manual: row.feed_in_manual,
|
||||
feed_use_manual: row.feed_use_manual,
|
||||
harvest_manual: row.harvest_manual,
|
||||
// Database dalam gram, form selalu dalam kg.
|
||||
harvest_weight_manual: row.harvest_weight_manual / 1000,
|
||||
manual_weight: row.manual_weight,
|
||||
};
|
||||
}
|
||||
|
||||
const DashboardManualInputForm: React.FC<Props> = ({ cycleId, currentDay, readOnly = false }) => {
|
||||
const [form, setForm] = useState<FormState>(() => emptyForm(currentDay));
|
||||
const [saved, setSaved] = useState<ManualInput | null>(null);
|
||||
const [rows, setRows] = useState<ManualInput[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const load = async (date: string) => {
|
||||
const list = await api.manualInputs.list({ cycle_id: cycleId });
|
||||
setRows(list);
|
||||
const match = list.find((row) => row.date === date) ?? null;
|
||||
setSaved(match);
|
||||
if (match) setForm(toForm(match));
|
||||
else {
|
||||
setForm((prev) => ({
|
||||
...emptyForm(currentDay),
|
||||
date,
|
||||
age_manual: prev.date === date ? prev.age_manual : currentDay,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const list = await api.manualInputs.list({ cycle_id: cycleId });
|
||||
if (cancelled) return;
|
||||
setRows(list);
|
||||
const today = todayIso();
|
||||
const match =
|
||||
list.find((row) => row.date === today) ?? (readOnly ? (list[0] ?? null) : null);
|
||||
setSaved(match);
|
||||
setForm(match ? toForm(match) : emptyForm(currentDay));
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(errorMessage(err));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cycleId, currentDay, readOnly]);
|
||||
|
||||
const onDateChange = async (date: string) => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await load(date);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mt-8">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (readOnly) return;
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const body: ManualInputWrite = {
|
||||
cycle: cycleId,
|
||||
date: form.date,
|
||||
age_manual: form.age_manual,
|
||||
mortality_manual: form.mortality_manual,
|
||||
feed_in_manual: form.feed_in_manual,
|
||||
feed_use_manual: form.feed_use_manual,
|
||||
harvest_manual: form.harvest_manual,
|
||||
// Form dalam kg, disimpan ke database dalam gram.
|
||||
harvest_weight_manual: form.harvest_weight_manual * 1000,
|
||||
manual_weight: form.manual_weight,
|
||||
};
|
||||
const row = await api.manualInputs.create(body);
|
||||
setSaved(row);
|
||||
setForm(toForm(row));
|
||||
setRows((prev) => {
|
||||
const others = prev.filter((item) => item.date !== row.date);
|
||||
return [row, ...others];
|
||||
});
|
||||
setNotice('Input manual tersimpan.');
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
className="bg-white rounded-xl shadow-sm p-5 space-y-4"
|
||||
>
|
||||
<h2 className="text-lg font-bold text-gray-800">Input manual</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{readOnly
|
||||
? 'Siklus ini sudah ditutup. Data di bawah hanya untuk ditinjau.'
|
||||
: 'Isi data harian di sini. Total dihitung otomatis. FCR, EEF, dan rata-rata panen belum dihitung.'}
|
||||
</p>
|
||||
{error && <Banner kind="error">{error}</Banner>}
|
||||
{notice && <Banner kind="success">{notice}</Banner>}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
<label className="block text-sm">
|
||||
Tanggal
|
||||
<input
|
||||
type="date"
|
||||
className={`${inputClass} mt-1`}
|
||||
value={form.date}
|
||||
onChange={(e) => void onDateChange(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Umur (hari)
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} mt-1`}
|
||||
value={form.age_manual}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setForm((f) => ({ ...f, age_manual: Number(e.target.value) }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Mortalitas (ekor)
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} mt-1`}
|
||||
value={form.mortality_manual}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setForm((f) => ({ ...f, mortality_manual: Number(e.target.value) }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Karung masuk
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} mt-1`}
|
||||
value={form.feed_in_manual}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setForm((f) => ({ ...f, feed_in_manual: Number(e.target.value) }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Karung Tuang
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} mt-1`}
|
||||
value={form.feed_use_manual}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setForm((f) => ({ ...f, feed_use_manual: Number(e.target.value) }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Panen (ekor)
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} mt-1`}
|
||||
value={form.harvest_manual}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setForm((f) => ({ ...f, harvest_manual: Number(e.target.value) }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Berat panen (kg)
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className={`${inputClass} mt-1`}
|
||||
value={form.harvest_weight_manual}
|
||||
disabled={readOnly}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, harvest_weight_manual: Number(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Berat AVG
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className={`${inputClass} mt-1`}
|
||||
value={form.manual_weight}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setForm((f) => ({ ...f, manual_weight: Number(e.target.value) }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{saved && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Total mortalitas {formatNumber(saved.mortality_manual_total, 0)} · karung masuk{' '}
|
||||
{formatNumber(saved.feed_in_manual_total, 0)} · karung tuang{' '}
|
||||
{formatNumber(saved.feed_use_manual_total, 0)} · panen{' '}
|
||||
{formatNumber(saved.harvest_manual_total, 0)} · berat panen{' '}
|
||||
{formatNumber(saved.harvest_weight_manual_total / 1000, 2)} kg
|
||||
</p>
|
||||
)}
|
||||
{!saved && rows.length > 0 && (
|
||||
<p className="text-xs text-gray-400">Belum ada input untuk tanggal ini.</p>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Menyimpan...' : 'Simpan'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardManualInputForm;
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface DashboardReportCtasProps {
|
||||
cycleClosed: boolean;
|
||||
hasDailySnapshot: boolean;
|
||||
hasEndCycleSnapshot: boolean;
|
||||
onOpenDaily: () => void;
|
||||
onOpenEndCycle: () => void;
|
||||
}
|
||||
|
||||
const buttonClass =
|
||||
'rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
const label = (hasSnapshot: boolean) => (hasSnapshot ? 'Lihat' : 'Buat');
|
||||
|
||||
const DashboardReportCtas: React.FC<DashboardReportCtasProps> = ({
|
||||
cycleClosed,
|
||||
hasDailySnapshot,
|
||||
hasEndCycleSnapshot,
|
||||
onOpenDaily,
|
||||
onOpenEndCycle,
|
||||
}) => (
|
||||
<div className="flex flex-wrap items-end justify-end gap-3">
|
||||
<button type="button" onClick={onOpenDaily} className={buttonClass}>
|
||||
{label(hasDailySnapshot)} Harian
|
||||
</button>
|
||||
<div className="flex flex-col items-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenEndCycle}
|
||||
disabled={!cycleClosed}
|
||||
className={buttonClass}
|
||||
>
|
||||
{label(hasEndCycleSnapshot)} Akhir Siklus
|
||||
</button>
|
||||
{!cycleClosed && (
|
||||
<p className="mt-1 text-xs text-slate-500">Tersedia setelah siklus ditutup</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default DashboardReportCtas;
|
||||
@@ -0,0 +1,566 @@
|
||||
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);
|
||||
|
||||
// cycle.current_day is 1-based (day 1 = start_date); report days are 0-based.
|
||||
const latestDay = selectedCycle ? Math.max(0, selectedCycle.current_day - 1) : 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;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import DashboardManualInputForm from '../DashboardManualInputForm.tsx';
|
||||
|
||||
const { createMock, listMock } = vi.hoisted(() => ({
|
||||
createMock: vi.fn(),
|
||||
listMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/apiClient.ts', () => ({
|
||||
api: {
|
||||
manualInputs: {
|
||||
list: listMock,
|
||||
create: createMock,
|
||||
},
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
errorMessage: (err: unknown) => (err instanceof Error ? err.message : 'Terjadi kesalahan'),
|
||||
}));
|
||||
|
||||
describe('DashboardManualInputForm', () => {
|
||||
beforeEach(() => {
|
||||
listMock.mockReset();
|
||||
createMock.mockReset();
|
||||
listMock.mockResolvedValue([]);
|
||||
createMock.mockResolvedValue({
|
||||
id: 1,
|
||||
cycle: 4,
|
||||
date: '2026-08-19',
|
||||
age_manual: 10,
|
||||
mortality_manual: 5,
|
||||
mortality_manual_total: 5,
|
||||
feed_in_manual: 8,
|
||||
feed_use_manual: 12,
|
||||
feed_in_manual_total: 8,
|
||||
feed_use_manual_total: 12,
|
||||
harvest_manual: 0,
|
||||
harvest_manual_total: 0,
|
||||
harvest_weight_manual: 0,
|
||||
harvest_weight_manual_total: 0,
|
||||
manual_weight: 320,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the user save daily manual fields without totals, FCR, or EEF', async () => {
|
||||
render(<DashboardManualInputForm cycleId={4} currentDay={10} />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: /Input manual/i })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/FCR/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/EEF/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/rata-rata panen/i)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Umur/i), { target: { value: '10' } });
|
||||
fireEvent.change(screen.getByLabelText(/Mortalitas/i), { target: { value: '5' } });
|
||||
fireEvent.change(screen.getByLabelText(/Karung masuk/i), { target: { value: '8' } });
|
||||
fireEvent.change(screen.getByLabelText(/Karung tuang/i), { target: { value: '12' } });
|
||||
fireEvent.change(screen.getByLabelText(/Berat panen/i), { target: { value: '8.5' } });
|
||||
expect(screen.getByLabelText(/Berat panen/i)).toHaveAttribute('step', '0.01');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Simpan/i }));
|
||||
|
||||
await waitFor(() => expect(createMock).toHaveBeenCalledTimes(1));
|
||||
const body = createMock.mock.calls[0]![0];
|
||||
expect(body).toMatchObject({
|
||||
cycle: 4,
|
||||
age_manual: 10,
|
||||
mortality_manual: 5,
|
||||
feed_in_manual: 8,
|
||||
feed_use_manual: 12,
|
||||
harvest_weight_manual: 8500,
|
||||
});
|
||||
expect(body).not.toHaveProperty('mortality_manual_total');
|
||||
expect(body).not.toHaveProperty('fcr_manual');
|
||||
expect(body).not.toHaveProperty('eef_manual');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import DashboardReportCtas from '../DashboardReportCtas.tsx';
|
||||
|
||||
describe('DashboardReportCtas', () => {
|
||||
it('disables end-cycle until the cycle is closed', () => {
|
||||
const onOpenDaily = vi.fn();
|
||||
const onOpenEndCycle = vi.fn();
|
||||
render(
|
||||
<DashboardReportCtas
|
||||
cycleClosed={false}
|
||||
hasDailySnapshot={false}
|
||||
hasEndCycleSnapshot={false}
|
||||
onOpenDaily={onOpenDaily}
|
||||
onOpenEndCycle={onOpenEndCycle}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /Akhir Siklus/i })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Harian/i }));
|
||||
expect(onOpenDaily).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user