Files
dashboard-cpsp/utils/reportExcel.ts
T

420 lines
12 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 ExcelJS from 'exceljs';
import type { DashboardReportPayload } from '../types/dashboardReport.ts';
import {
countingTotals,
feedInTotals,
feedOutTotals,
feedUseTotals,
} from './reportTotals.ts';
import { formatDateId, formatDateRange, formatNumber, formatRatio } from './format.ts';
const COLORS = {
headerFill: 'FFF8FAFC',
headerFont: 'FF475569',
accentFill: 'FF6D28D0',
accentFont: 'FFFFFFFF',
totalFill: 'FFF1F5F9',
zebraFill: 'FFFAFAFA',
border: 'FFE2E8F0',
titleFont: 'FF111827',
metaFont: 'FF374151',
};
const thinBorder: Partial<ExcelJS.Borders> = {
top: { style: 'thin', color: { argb: COLORS.border } },
left: { style: 'thin', color: { argb: COLORS.border } },
bottom: { style: 'thin', color: { argb: COLORS.border } },
right: { style: 'thin', color: { argb: COLORS.border } },
};
type CellValue = string | number | null;
const isPresent = (value: number | null | undefined): value is number =>
typeof value === 'number' && Number.isFinite(value);
const dash = (value: number | null | undefined): CellValue =>
isPresent(value) ? value : '-';
const na = (value: number | null | undefined): CellValue =>
isPresent(value) ? value : 'N/A';
const pct = (value: number | null | undefined): CellValue =>
isPresent(value) ? `${value.toFixed(1)}%` : 'N/A';
const qty = (value: number | null | undefined, maxDecimals = 0, suffix = ''): CellValue => {
if (!isPresent(value)) return 'N/A';
return `${formatNumber(value, maxDecimals, 0, true)}${suffix}`;
};
const ratio = (value: number | null | undefined): string =>
isPresent(value) ? formatRatio(value, 3) : 'N/A';
const harvestKg = (grams: number | null | undefined): CellValue => {
if (!isPresent(grams)) return 'N/A';
return `${formatNumber(grams / 1000, 1)} kg`;
};
const latestValidRatio = (
rows: Array<{ iotFcr?: number | null; iotEef?: number | null }>,
key: 'iotFcr' | 'iotEef'
): number | null => {
for (let i = rows.length - 1; i >= 0; i -= 1) {
const value = rows[i]?.[key];
if (isPresent(value)) return value;
}
return null;
};
function styleHeaderRow(row: ExcelJS.Row, colCount: number): void {
row.height = 22;
for (let c = 1; c <= colCount; c += 1) {
const cell = row.getCell(c);
cell.font = { bold: true, color: { argb: COLORS.headerFont }, size: 11 };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: COLORS.headerFill } };
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
cell.border = thinBorder;
}
}
function styleDataRow(row: ExcelJS.Row, colCount: number, stripe: boolean): void {
for (let c = 1; c <= colCount; c += 1) {
const cell = row.getCell(c);
cell.font = { size: 11, color: { argb: COLORS.titleFont } };
cell.alignment = {
vertical: 'middle',
horizontal: c === 1 ? 'left' : 'right',
};
if (stripe) {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: COLORS.zebraFill } };
}
cell.border = thinBorder;
}
}
function styleTotalRow(row: ExcelJS.Row, colCount: number): void {
row.height = 22;
for (let c = 1; c <= colCount; c += 1) {
const cell = row.getCell(c);
cell.font = { bold: true, size: 11, color: { argb: COLORS.titleFont } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: COLORS.totalFill } };
cell.alignment = {
vertical: 'middle',
horizontal: c === 1 ? 'left' : 'right',
};
cell.border = {
...thinBorder,
top: { style: 'medium', color: { argb: COLORS.border } },
};
}
}
function autoFitColumns(sheet: ExcelJS.Worksheet, min = 10, max = 42): void {
sheet.columns.forEach((column) => {
let maxLen = min;
column.eachCell?.({ includeEmpty: false }, (cell) => {
const text =
cell.value instanceof Date
? cell.value.toLocaleDateString('id-ID')
: String(cell.value ?? '');
maxLen = Math.max(maxLen, text.length);
});
column.width = Math.min(max, Math.max(min, maxLen + 2));
});
}
function addSheetTitle(sheet: ExcelJS.Worksheet, title: string, colCount: number): void {
sheet.mergeCells(1, 1, 1, colCount);
const cell = sheet.getCell(1, 1);
cell.value = title;
cell.font = { bold: true, size: 13, color: { argb: COLORS.titleFont } };
cell.alignment = { vertical: 'middle', horizontal: 'left' };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: COLORS.headerFill } };
cell.border = {
...thinBorder,
bottom: { style: 'medium', color: { argb: COLORS.accentFill } },
};
sheet.getRow(1).height = 26;
}
function addTable(
sheet: ExcelJS.Worksheet,
title: string,
headers: string[],
rows: CellValue[][],
totalRow?: CellValue[] | null
): void {
const colCount = headers.length;
addSheetTitle(sheet, title, colCount);
const headerRow = sheet.addRow(headers);
styleHeaderRow(headerRow, colCount);
rows.forEach((values, index) => {
const row = sheet.addRow(values);
styleDataRow(row, colCount, index % 2 === 1);
});
if (totalRow) {
const row = sheet.addRow(totalRow);
styleTotalRow(row, colCount);
}
sheet.views = [{ state: 'frozen', ySplit: 2, activeCell: 'A3' }];
autoFitColumns(sheet);
}
function addSummarySheet(workbook: ExcelJS.Workbook, payload: DashboardReportPayload): void {
const isEndCycle = payload.reportType === 'end_cycle';
const title = isEndCycle ? 'Laporan Akhir Siklus' : 'Laporan Harian';
const lastDate = payload.counting[payload.counting.length - 1]?.date ?? payload.cycleStart;
const printedDate = new Date(payload.generatedAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
const sheet = workbook.addWorksheet('Ringkasan');
sheet.getColumn(1).width = 22;
sheet.getColumn(2).width = 48;
const addMeta = (label: string, value: string | number, bold = false): void => {
const row = sheet.addRow([label, value]);
row.getCell(1).font = { bold: true, size: 11, color: { argb: COLORS.metaFont } };
row.getCell(2).font = {
bold,
size: bold ? 14 : 11,
color: { argb: COLORS.titleFont },
};
row.height = bold ? 24 : 20;
};
sheet.getCell('A1').value = 'Dashboard CPSP';
sheet.getCell('A1').font = { bold: true, size: 10, color: { argb: COLORS.accentFill } };
sheet.addRow([]);
addMeta('Judul', title, true);
addMeta('Lokasi', `${payload.area} — ${payload.kandangName}`);
addMeta(
'Periode',
`Hari ke-0 s/d ke-${payload.upToDay} (${formatDateId(payload.cycleStart)} – ${formatDateId(lastDate)})`
);
addMeta('Siklus', formatDateRange(payload.cycleStart, payload.cycleEnd));
addMeta('Dicetak', printedDate);
if (isEndCycle) {
const finalFcr = latestValidRatio(payload.fcr, 'iotFcr');
const finalEef = latestValidRatio(payload.eef, 'iotEef');
sheet.addRow([]);
addMeta('FCR akhir', ratio(finalFcr), true);
addMeta('EEF akhir', ratio(finalEef), true);
}
sheet.addRow([]);
const navHeader = sheet.addRow(['Sheet', 'Isi']);
styleHeaderRow(navHeader, 2);
const navRows: Array<[string, string]> = [
['Hitung Ayam', 'Stok, mortalitas, panen, dan akurasi hitung harian'],
['Berat Ayam', 'Rata-rata berat manual vs IoT dan akurasi'],
['Karung Masuk', 'Pakan masuk manual vs IoT'],
['Karung Dituang', 'Pakan dituang manual vs IoT'],
['Karung Keluar', 'Pakan keluar manual vs IoT'],
];
if (isEndCycle) {
navRows.push(['FCR', 'Data pendukung FCR per umur']);
navRows.push(['EEF', 'Data pendukung EEF per umur']);
}
navRows.forEach(([name, desc], index) => {
const row = sheet.addRow([name, desc]);
styleDataRow(row, 2, index % 2 === 1);
});
}
export async function downloadReportExcel(payload: DashboardReportPayload): Promise<void> {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'Dashboard CPSP';
workbook.created = new Date();
addSummarySheet(workbook, payload);
const countingTotal = countingTotals(payload.counting);
addTable(
workbook.addWorksheet('Hitung Ayam'),
'Hitung Ayam harian',
[
'Tanggal',
'Umur (hari)',
'Stok awal',
'Mortalitas',
'Panen',
'Berat panen',
'Stok akhir',
'IoT',
'Akurasi %',
],
payload.counting.map((row) => [
formatDateId(row.date),
row.day,
na(row.countBefore),
na(row.mortality),
na(row.harvest),
na(row.harvestWeight),
na(row.countAfter),
na(row.iotCount),
pct(row.accuracy),
]),
countingTotal
? [
countingTotal.label,
'—',
dash(countingTotal.countBefore),
na(countingTotal.mortality),
na(countingTotal.harvest),
na(countingTotal.harvestWeight),
dash(countingTotal.countAfter),
dash(countingTotal.iotCount),
countingTotal.accuracy == null ? '-' : pct(countingTotal.accuracy),
]
: null
);
addTable(
workbook.addWorksheet('Berat Ayam'),
'Berat Ayam harian',
[
'Tanggal',
'Umur (hari)',
'Rata-rata manual',
'Rata-rata IoT',
'Keseragaman',
'Selisih',
'Akurasi %',
],
payload.weight.map((row) => [
formatDateId(row.date),
row.day,
na(row.avgManual),
na(row.avgIot),
pct(row.uniformity),
na(row.difference),
pct(row.accuracy),
])
);
const feedInTotal = feedInTotals(payload.feedIn);
addTable(
workbook.addWorksheet('Karung Masuk'),
'Karung masuk',
['Tanggal', 'Manual masuk', 'IoT masuk', 'Akurasi %'],
payload.feedIn.map((row) => [
formatDateId(row.date),
na(row.manualIn),
na(row.iotIn),
pct(row.accuracy),
]),
feedInTotal
? [
feedInTotal.label,
na(feedInTotal.manualIn),
na(feedInTotal.iotIn),
pct(feedInTotal.accuracy),
]
: null
);
const feedUseTotal = feedUseTotals(payload.feedUse);
addTable(
workbook.addWorksheet('Karung Dituang'),
'Karung dituang',
['Tanggal', 'Umur (hari)', 'Manual dituang', 'IoT dituang', 'Akurasi %'],
payload.feedUse.map((row) => [
formatDateId(row.date),
row.day,
na(row.manualUse),
na(row.iotUse),
pct(row.accuracy),
]),
feedUseTotal
? [
feedUseTotal.label,
'—',
na(feedUseTotal.manualUse),
na(feedUseTotal.iotUse),
pct(feedUseTotal.accuracy),
]
: null
);
const feedOutTotal = feedOutTotals(payload.feedOut);
addTable(
workbook.addWorksheet('Karung Keluar'),
'Karung keluar',
['Tanggal', 'Manual keluar', 'IoT keluar', 'Akurasi %'],
payload.feedOut.map((row) => [
formatDateId(row.date),
na(row.manualOut),
na(row.iotOut),
pct(row.accuracy),
]),
feedOutTotal
? [
feedOutTotal.label,
na(feedOutTotal.manualOut),
na(feedOutTotal.iotOut),
pct(feedOutTotal.accuracy),
]
: null
);
if (payload.reportType === 'end_cycle') {
addTable(
workbook.addWorksheet('FCR'),
'Data Pendukung FCR',
['Umur', 'Total Pakan', 'Bobot IoT', 'Ayam Hidup', 'Total Bobot Panen', 'FCR'],
payload.fcr.map((row) => [
`Hari ${row.day}`,
qty(row.feedTotal, 0, ' karung'),
qty(row.iotWeight, 2, row.iotWeight != null ? ' g' : ''),
qty(row.chickenLife, 0, ' ekor'),
harvestKg(row.harvestWeightTotal),
ratio(row.iotFcr),
])
);
addTable(
workbook.addWorksheet('EEF'),
'Data Pendukung EEF',
[
'Umur',
'Persentase Ayam Hidup',
'BW Rata-rata Panen',
'Umur Rata-rata Panen',
'FCR',
'EEF',
],
payload.eef.map((row) => [
`Hari ${row.day}`,
qty(row.chickenLifePercentage, 3, '%'),
qty(row.iotAverageHarvestBw, 2, row.iotAverageHarvestBw != null ? ' g' : ''),
qty(row.iotAverageDayWeight, 2),
ratio(row.iotFcr),
ratio(row.iotEef),
])
);
}
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const period =
payload.reportType === 'end_cycle' ? 'Akhir-Siklus' : `Harian-Hari-${payload.upToDay}`;
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `Laporan-${period}-${payload.area}-${payload.kandangName}-${payload.cycleStart}_sd_${payload.cycleEnd}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
}