Files
dashboard-cpsp/utils/reportExcel.ts
T

468 lines
14 KiB
TypeScript

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';
import { buildReportSummary, formatSummaryValue } from './reportSummary.ts';
const COLORS = {
headerFill: 'FFF8FAFC',
headerFont: 'FF475569',
accentFill: 'FF6D28D0',
accentDeep: 'FF5B21B6',
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`;
};
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 styleHighlightKpiRow(row: ExcelJS.Row, colCount: number): void {
row.height = 28;
const highlightBorder: Partial<ExcelJS.Borders> = {
top: { style: 'medium', color: { argb: COLORS.accentDeep } },
left: { style: 'medium', color: { argb: COLORS.accentDeep } },
bottom: { style: 'medium', color: { argb: COLORS.accentDeep } },
right: { style: 'medium', color: { argb: COLORS.accentDeep } },
};
for (let c = 1; c <= colCount; c += 1) {
const cell = row.getCell(c);
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: COLORS.accentFill } };
cell.font = {
bold: true,
size: c === 2 ? 14 : 11,
color: { argb: COLORS.accentFont },
};
cell.alignment = {
vertical: 'middle',
horizontal: c === 2 ? 'center' : 'left',
};
cell.border = highlightBorder;
}
}
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(
'Berat DOC',
payload.docWeight == null || !Number.isFinite(payload.docWeight)
? '—'
: `${formatNumber(payload.docWeight, 1, 0, true)} gram`
);
addMeta('Dicetak', printedDate);
sheet.addRow([]);
const kpiTitle = sheet.addRow(['Ringkasan KPI']);
kpiTitle.getCell(1).font = {
bold: true,
size: 12,
color: { argb: COLORS.accentFill },
};
sheet.getColumn(3).width = 12;
const kpiHeader = sheet.addRow(['Metrik', 'Nilai', 'Satuan']);
styleHeaderRow(kpiHeader, 3);
const summary = buildReportSummary(payload);
let insertedHighlightGap = false;
summary.metrics.forEach((metric, index) => {
const isHighlight = metric.key === 'fcr' || metric.key === 'eef';
if (isHighlight && !insertedHighlightGap) {
sheet.addRow([]);
insertedHighlightGap = true;
}
const row = sheet.addRow([
metric.label,
formatSummaryValue(metric),
metric.unit || '—',
]);
if (isHighlight) {
styleHighlightKpiRow(row, 3);
} else {
styleDataRow(row, 3, index % 2 === 1);
row.getCell(1).alignment = { vertical: 'middle', horizontal: 'left' };
row.getCell(2).alignment = { vertical: 'middle', horizontal: 'right' };
row.getCell(3).alignment = { vertical: 'middle', horizontal: 'left' };
}
});
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);
}